Skip to content

feat!: an OpenAI-compatible LLM Provider - #78

Merged
davideimola merged 16 commits into
mainfrom
feat/openai-compatible-provider
Jul 30, 2026
Merged

feat!: an OpenAI-compatible LLM Provider#78
davideimola merged 16 commits into
mainfrom
feat/openai-compatible-provider

Conversation

@davideimola

Copy link
Copy Markdown
Contributor

Closes #69.

Argus spoke to exactly one LLM Provider. argus.yaml already accepted a type
field and already documented openai and ollama as reserved values, but
nothing in the codebase selected an implementation from it — the daemon's
Provider factory constructed a Gemini client unconditionally, so type: openai
silently built the wrong client and failed later, elsewhere, pointing the
operator at the wrong problem.

Argus now speaks the OpenAI-compatible chat-completions protocol as a second
Provider. One adapter reaches OpenAI, OpenRouter, Groq, Together, DeepSeek,
Mistral and Cerebras, plus every local runtime that implements the same protocol
(Ollama, vLLM, LM Studio, llama.cpp). The type is named for the protocol, not
the vendor
openai-compatible, never openai — because Argus implements a
wire protocol and certifies nobody's server.

Two mechanisms make that promise concrete rather than a disclaimer: a documented
Requirements contract (docs/guide/llm-providers.md), and a live capability
probe in argus doctor that verifies on the user's machine, against the user's
model, at the moment they configure it. The compatibility table is
community-fed: the artifact a contributor pastes into a PR is their argus doctor output.

BREAKING CHANGE

The USD cost figure is gone; token counts stay. pkg/budget is deleted in full,
and cost_usd is removed from the UDS usage frame, the TUI status line, the
/cost readout, the chat client and the argus init interview.

Of the three layers pkg/budget promised, two — the per-session token cap and
the per-day USD cap — were dead code, never constructed by anything. The
third, the per-call USD figure, was a readout, not a control, computed from a
hardcoded Gemini-only price table duplicated across two packages. Under
arbitrary endpoints that table makes the figure wrong by default: an operator on
a paid non-Gemini endpoint would be shown a cost of zero, and a false zero is
worse than no number. The real runaway-loop guard already exists in the right
place — the agent's turn ceiling, 50 by default, ending a run with
ErrMaxTurnsExceeded.

ProtocolVersion is bumped to 2 accordingly. JSON tolerates the absent field,
which is exactly the hazard: a stale v1 client would render the missing
cost_usd as $0.0000. The version gate turns that into "update the argus
binary" instead.

Recorded in ADR 0021, including that reinstating cost caps is out of scope by
decision — a future spend control would be new work, not a restoration.

What's in it

Provider

  • pkg/provider/openaicompat — a hand-written net/http + encoding/json
    client, no new dependencies. Tolerance is the feature, not a compromise: a
    strictly typed SDK written for the real OpenAI rejects what compatible servers
    legitimately return. Absent usage, non-standard finish_reason, empty or
    duplicated tool-call ids, several tool calls in one response, tool arguments
    sent as an object instead of a JSON string — each has a test against an
    httptest stand-in endpoint.
  • pkg/provider/factory — one place that knows which implementations exist.
    Neither construction site (the daemon, the argus init interview) imports a
    concrete implementation any more. Spec and the type constants live in
    pkg/provider; only the switch sits one directory below, because every
    implementation imports pkg/provider and a switch over them there would
    cycle.
  • Model ids resolve to a Provider by explicit qualification —
    provider-name/model-id, split on the first slash only, so an OpenRouter
    id that already contains a slash still qualifies. A bare id stays valid
    whenever it resolves to exactly one Provider; existing Gemini configurations
    keep working untouched.
  • Optional max_output_tokens, omitted by default. It is refused on a
    gemini entry rather than ignored: the Gemini client accepts no ceiling, and a
    lever that quietly does nothing delivers the same truncated Report it exists to
    prevent.

argus doctor

  • A capability probe: GET /models first — settling reachability, key validity
    and the existence of the configured model id before a token is spent — then a
    minimal generation carrying one throwaway tool declaration, asserting a tool
    call actually arrives. Servers that accept tool declarations and then ignore
    them are the common local small-model failure, and documentation alone cannot
    cover it.
  • A server that rejects tool declarations gets that translated into a plain
    statement that the model does not support tool calling, with the server's own
    words kept after it. The translation requires body evidence, not a bare
    4xx: Tools is sent on every turn, so a status-only rule would label a
    turn-30 context overflow "your model cannot call tools".
  • The API-key check is now derived from the configured Providers. It was a
    hardcoded GEMINI_API_KEY check at SeverityRequired, so an
    openai-compatible-only operator failed argus doctor blockingly for a key
    they correctly do not have.
  • Fixes a case found while testing: two compatible Providers plus an
    unresolvable default_model gave ✓ argus.yaml and exit 0 for a config no
    Session can start.

argus init

  • Endpoint presets, each URL verified against that service's own documentation
    and pinned in a test with its exact path. This is where the protocol's
    sharpest footgun lives: the /v1 suffix is mandatory, Groq does not serve at
    the domain root, and DeepSeek does — so the custom-URL validator deliberately
    does not require /v1.
  • The API key step is optional for the compatible type. It was mandatory, which
    would have blocked a local-runtime user at the first screen.
  • OPENAI_API_KEY / OPENAI_BASE_URL pre-fill from .env or the shell.
    providerEnvVar needed an explicit case: the fallback rule would have emitted
    OPENAI-COMPATIBLE_API_KEY, and a test now pins that every offered type's
    variable names are shell-safe.

Documentation

  • ADR 0020 (Provider design) and ADR 0021 (cost-control removal).
  • docs/guide/llm-providers.md — the Requirements contract in three categories,
    because they fail differently: hard requirements, quality factors, and explicit
    non-requirements (no streaming, multimodal, embeddings or JSON mode — Argus
    uses tools, not constrained decoding). Plus known limits, stated rather than
    discovered: prompts were written against Gemini and other families are
    untested, there is no retry or backoff, and Azure OpenAI is not supported.
  • The glossary gains the LLM Provider entry it explicitly reserved the word
    for and never defined.
  • Corrections where documentation claimed a control the code does not have. The
    viewer Role was described as gated by a "global budget cap"; no such cap ever
    existed. Four earlier ADRs (0002, 0004, 0008, 0018) leaned on it, ADR 0002
    load-bearingly — it justified letting viewer chat. All now say what is
    enforced, and that nothing bounds spend.

Reviewing this

The three commits worth reading closely are ecfdf17 (the adapter and its
tolerance tests), d6cb8de (the factory and both call sites) and 20dd5d1 (the
probe and the key-check severity matrix). The four test files that substitute the
daemon's NewProvider seam are byte-identical throughout — that is the
regression signal that per-Session Provider acquisition is intact.

pkg/agent/context_floor_test.go is a growth guard, not a token count: it
measures the assembled system prompt plus tool declarations at a full registry
(1,188 B + 7,765 B = 8,953 B), from which the guide states a 32k-token floor with
its 4 bytes/token conversion declared. Counting tokens would need a tokenizer
Argus should not add; catching a system prompt that silently doubled is the
better job.

Known gaps

  • User story 22 is only half served. Token counts reach the interactive chat
    channel; Session.Usage() has no production caller, so Reviews via MCP and
    GitHub surface no token figure. Unchanged by this PR, and left alone because
    closing it means touching three channels' surfaces.
  • Skill RBAC is documented as enforced in three places outside this PR's
    scope
    (docs/guide/skills.md:20, docs/adr/0005:161-164,
    pkg/skill/skill.go:5-8) and is not — no skill tool consults a Role. The
    glossary entry that contradicted the corrected Role entry is fixed here; the
    rest is a separate issue.
  • A separate authorization finding, not caused by this work:
    start_review_local re-roots the file tools at any absolute directory that
    exists, with no allowlist, and the GitHub PR-comment path has no Role gate. Any
    resolved Person can have the agent read an arbitrary directory back into a PR
    thread. Filed separately.

The glossary defined CodeHost and explicitly reserved the word "provider"
for the LLM Provider, then never defined it. Add the entry: the LLM
backend Argus generates through, addressed by a `type` that names a
protocol rather than a vendor (hence `openai-compatible`, never
`openai` — Argus implements a wire protocol, it does not certify
anybody's server), one implementation per protocol, selected per Session
by a model id that must resolve to exactly one configured Provider. A
local runtime is not a Provider type; it is a server that speaks one.

Correct two Role descriptions that rested on a control which has never
existed. `viewer` was documented as consuming tokens "gated by the global
budget cap" and `admin` as able to "override budget caps", but the
per-session token cap and per-day USD cap in pkg/budget are constructed
nowhere outside their own test. State what is actually enforced instead:
the authorization gates at the Tool layer, `max_concurrent_sessions`, and
the agent's turn ceiling — none of which bound spend. Record that
ADR 0002 and ADR 0018 still invoke the retired cap.

Excise the same false claim from both copies of the authorization skill
design, which sold pkg/budget as a competitive advantage over
per-developer AI subscriptions. The honest argument survives on token
counts reported per Session; the "organization budget ceiling" does not.

Refs #69
…as ADRs

ADR 0020 covers the second LLM Provider as one argument: the Provider type
names the chat-completions protocol (`openai-compatible`) rather than a
vendor, `ollama` leaves the reserved-type list because a local runtime is a
base URL and not a type, the client is hand-written because tolerance of
approximately-OpenAI servers is the requirement an SDK cannot meet, and no
server is certified — `argus doctor` probes capability on the user's machine
and the compatibility table is community-fed.

ADR 0021 records the removal of cost controls: `pkg/budget` in full plus the
USD figure on the wire, in the local TUI channel, in the chat client and in
the init interview. Two of the three layers were never constructed outside
their own tests; the third was a readout computed from a Gemini-only price
table duplicated across `cmd` and `pkg/daemon`, which under arbitrary
endpoints shows a false zero. The real runaway-loop guard is the agent's turn
ceiling. ADRs 0002, 0004 and 0018 each invoke a budget cap that was never
built, so 0021 amends all three while leaving their decisions standing.

Refs #69
Argus supported one Provider type in practice, so a model id could be
matched to its Provider by a naive name/type prefix. With a second type
arriving (`openai-compatible`, the protocol rather than any vendor) that
is no longer enough: two Providers can serve the same bare id, and an
OpenRouter id carries a slash of its own.

Add `ProviderForModel`, which resolves an arbitrary model id — not only
the configured default, since a Session may override it — to the Provider
entry, its configured name, and the bare id to send on the wire:

  1. the canonical qualified form `provider-name/model-id`, split on the
     first slash only, so `openrouter/vendor/model` qualifies correctly;
  2. an unqualified id when exactly one Provider is configured;
  3. an unqualified id whose Provider name or type is its prefix, which
     is how existing Gemini configurations keep working untouched.

An id several Providers could serve is ambiguous rather than resolved at
random: the error names the candidates and the qualified form to write.
`ProviderForDefaultModel` keeps its signature and becomes a wrapper.

Also document the supported `type` values as `gemini` and
`openai-compatible` — a protocol, not a vendor, which is why Ollama is
not a type but a server that speaks one — and add the optional
`max_output_tokens` ceiling. Omitting it sends no ceiling; it exists
because a low server-side cap truncates a long Report mid-write and
produces a mutilated file rather than an error.

Refs #69
`pkg/budget` promised three layers of cost protection and delivered one.
The per-session token cap and the per-day USD cap were never constructed
anywhere — dead code behind a package doc that called the first one "the
runaway-loop guard". The third layer, the per-call USD figure, is a readout
rather than a control, computed from a hardcoded Gemini-only price table
duplicated in cmd/ and pkg/daemon/. Under arbitrary endpoints that table is
wrong by default: an operator on a paid non-Gemini endpoint would be shown a
cost of zero.

So the package goes and token counts stay. They arrive in the provider's
usage response, are correct for every model and every endpoint, and need no
table. An operator who wants a cost figure has their provider's rates.

The real runaway-loop guard is untouched: it is the agent's turn ceiling
(pkg/agent, default 50), which ends a run with ErrMaxTurnsExceeded.

The TUI status line and /cost now read tokens in / out with no currency
cell, and the tests that asserted a dollar figure assert the surviving token
readout instead — including, negatively, that no "$" reaches the display.

BREAKING CHANGE: the UDS usage frame no longer carries cost_usd, and
ProtocolVersion is 2. JSON decoding tolerates the absent field, which is
precisely the hazard: a v1 client would keep its cost cell and read the
missing field as $0.0000. Gating the skew turns that silent wrong figure
into "update the argus binary". Also removed: daemon.Context.Pricing,
the cost argument on daemon.RunCallbacks.OnUsage, the third return value of
daemon.Session.Usage(), tui.Model.CostUSD(), and tui.AgentUsageMsg.CostUSD.

Refs #69
Argus spoke to exactly one LLM Provider, so evaluating it meant obtaining a
Google API key first. This adds a second Provider that speaks the
OpenAI-compatible chat-completions protocol, which unlocks the hosted services
and every local runtime that implements the same wire format.

The Provider is named for the protocol, not for a vendor: Argus implements a
wire format, it does not certify anybody's server. The client is hand-written
net/http + encoding/json for the same reason — Argus talks to servers that are
only approximately OpenAI, and a strictly typed SDK rejects responses those
servers legitimately return. Tolerance is the feature, so absent usage,
invented finish reasons, empty or duplicated tool-call ids, arguments sent as
an object, and content sent as parts are all accepted rather than rejected.

Tool-call id correlation gets particular care: this protocol matches results to
calls by id where Gemini matches by name, so a server that emits empty or
repeated ids would otherwise break every turn carrying more than one call.
Argus fills those in deterministically and echoes back what it handed out.

A refusal of the tool declarations is translated into a plain statement that
the model does not support tool calling, since that verdict is the difference
between a bug report and a configuration fix. The translation requires the
server to have actually said something about tools: Argus declares its tools on
every turn, so status alone would misreport a context overflow as a missing
capability.

No retry logic and no request timeout, matching the Gemini Provider rather than
regressing from it. No new dependencies.

Tests drive an httptest server standing in for the endpoint, asserting on the
request body Argus puts on the wire and the resulting provider.Response.

Refs #69
ADR 0021 records that `pkg/budget`'s two caps were dead code and that the
USD figure was a readout, never a control. Four earlier ADRs still invoke
that phantom "global budget cap", in one case as the compensating control
for a permission decision. Correct them at the point of each claim,
preserving the original wording as the record of what was decided.

- 0002: `admin` no longer "overrides budget"; `viewer` chat spend is
  gated by nothing; the Consequences bullet that let `viewer` chat
  "because the budget cap is the guardrail" now says the guardrail was
  never there, keeps the decision, and states what does exist
  (`max_concurrent_sessions`, the turn ceiling) and that neither bounds
  spend.
- 0004: a Session owns a cumulative token counter, not a "per-session
  token budget".
- 0018: the DoS vector was already unmitigated when the ADR was written,
  because the cap it named was dead code — deleting the cap did not
  create the vector; and there is no "hard spend backstop".
- 0008: "spend Argus's LLM budget" reads as an allotment; it is the
  operator's tokens, and nothing bounds them.

0002, 0004 and 0018 now carry an "amended by ADR 0021" status note,
mirroring 0021's own `Amends:` list and the convention already used by
0003, 0004, 0011 and 0012. 0008 needs no amendment note: its wording was
imprecise, not false, and 0021 does not list it.

Refs #69 (user story 27).
The Provider step was a select whose Validate rejected everything but
Gemini, and the API key step was unconditionally mandatory. Both change:

- the type step offers gemini and openai-compatible (the dead anthropic
  option and the "not yet implemented" validator are gone), labelled so a
  user sees the compatible type covers hosted services and local runtimes
  alike — it names a protocol, not a vendor;
- choosing it offers endpoint presets carrying complete, verified base
  URLs (OpenAI, OpenRouter, Groq, Together, DeepSeek, Mistral, Cerebras,
  Ollama) plus a custom entry, so the preset list never limits what Argus
  can be pointed at. Presets exist to encode the /v1 footgun and the
  services that do not serve the API at the domain root;
- the API key becomes optional for the compatible type, so a local
  runtime is not blocked at the first screen. No key means no api_key
  entry in argus.yaml and no .env write at all;
- OPENAI_API_KEY and OPENAI_BASE_URL pre-fill the interview, from the
  .env file or the shell. providerEnvVar gains an explicit
  openai-compatible case: its fallback would have produced
  OPENAI-COMPATIBLE_API_KEY, which no shell can export;
- the model is free text for the compatible type. Enumerating an
  arbitrary endpoint belongs in the doctor probe, not mid-form.

The huh form stays untested by design; the pure parts extracted from it
(env-var naming, preset-to-URL mapping, the written config entry) are
table-tested.

Refs #69
The `viewer` entry claimed its gates "live at the Tool layer, so a refusal
is uniform however the agent is prompted." The code says otherwise, and
this is the second wrong control the entry has named: the first was a
budget cap that never existed, this one was Tool-layer enforcement that
does not exist yet.

What the code enforces, verified:

- Every Role check lives in a channel. `pkg/tool` references neither Role
  nor Principal. `buildRegistry` takes no Principal, so every Session's
  base tool set is identical — `write_context`, `start_review_local` and
  `start_review_github` included.
- A viewer meets exactly three refusals: MCP `review` (checked before the
  session is built), and the GitHub channel's `suppress_finding` /
  `rescope_review`, which check the Role inside Execute because the
  channel builds them carrying the commenter's Role.
- Nothing stops a viewer reaching the state-writing tools past a permitted
  entry point. `dispatchComment` has no Role gate at all and seeds the turn
  with the commenter's raw text; `consult`'s seed forbids only
  `add_finding` / `finalize_report`, never `write_context` or
  `start_review_*`. The system prompt carries no RBAC language.

So the guarantee is per-channel, not per-Tool, and the refusal is not
uniform however the agent is prompted. The entry now says so, names the
Tool layer as the destination, and marks it not yet built — the register
the `Skill` entry already uses. No enforcement is added or proposed here;
per the PRD this slice corrects documentation to match enforcement.

ADR 0021's Consequences quoted ADR 0002's `viewer` guardrail clause but
attributed the `admin` "budget overrides" claim only to the glossary. ADR
0002's `admin` bullet literally read "overrides budget" too, and the
amendment covered both, so the quote is added. The other verbatim quotes
of ADRs 0002, 0004 and 0018 were checked against the amended text on this
branch and match.

Refs #69 (user story 26).
Argus is an agent loop that lives on tool calls, and `openai-compatible`
names a protocol Argus implements rather than a server it certifies. That
promise is only honest if the requirements are written down and verifiable,
so this adds the Requirements contract the name leans on.

New guide page, in three groups because they fail differently:

  - Hard requirements — function calling, tool-call id correlation, a
    context-window floor, and a system instruction that is honoured. The
    last two are the insidious ones: neither raises an error, so each entry
    describes the symptom a reader would actually observe.
  - Quality factors — one tool call per turn, JSON Schema fidelity, and a
    server-side output ceiling. These burn turns or truncate a Report
    rather than stopping the loop.
  - Non-requirements — streaming, multimodal input, embeddings, and JSON
    mode. Argus uses tools, not constrained decoding, and nobody should
    rule out a usable model over a feature Argus never calls.

The tool-call id entry describes what the adapter actually does rather than
the idealised failure the spec predicted: empty and duplicated ids are
repaired, positionally and deterministically, and echoed back consistently.
What survives is the next turn, where a server may not recognise an id it
never issued — so the requirement is stated with that symptom instead of a
blanket "it breaks".

The context-window floor is derived from a measurement, not invented. A new
regression guard in pkg/agent measures the assembled system prompt plus the
full tool declaration set at a Session's whole registry: 8,953 B today
(1,188 B of prompt, 7,765 B across 17 declarations). The guide states the
floor with its 4-bytes-per-token ratio declared, so a reader can redo the
arithmetic against their own model, and names the three things that push a
real deployment above the measured figure — the operator's own SOUL and
MEMORY, the GitHub channel's two request-scoped tools, and the wire
envelope. Bytes rather than tokens because counting tokens needs a tokenizer
that differs per model family and that Argus should not carry; the test's
real value is catching a silent doubling, so it fails with a message naming
what grew and which of the two fixes applies. A second test guards the
opposite direction, since a tool that stopped reaching the model would make
the measurement look better while breaking the agent.

The compatibility table ships empty. Verifying specific models is
explicitly out of scope, so seeding it with plausible rows nobody ran would
make exactly the claim this design refuses to make. The documented
reporting process asks for `argus doctor` output as the artifact.

configuration.md gains the Provider reference: both types, `url`, the
optional `api_key`, `max_output_tokens` with its rationale, and model-id
qualification as ProviderForModel actually resolves it. It also loses a
stale claim — `auto_enroll: true` was said to let a stranger "spend your
budget", and there is no budget: token spend is reported and bounded by
nothing. The warning keeps its force, because unbounded spend is the
plainer version of the same risk.
… sites go through it

`type` on a providers: entry was read by nobody: the daemon's Provider
factory called gemini.New unconditionally and `argus init` did the same,
so `type: openai-compatible` silently built a Gemini client and failed
later with an authentication error pointing at the wrong problem.

A Spec (Provider type, key, base URL, bare model id, output ceiling) now
travels from a caller to one constructor that switches over the
implementations. Spec and the type constants live in pkg/provider, where
the Provider vocabulary belongs; the switch lives one directory below
because every implementation imports pkg/provider and a switch over them
there would be an import cycle. Neither construction site imports a
concrete implementation any more, an unknown type fails with a message
naming the supported ones, and a negative output ceiling — which nothing
validated — is refused.

The daemon resolves a model id through config.ProviderForModel, so a
qualified `provider-name/model-id` picks its Provider and only the bare
half reaches the wire, per-Session overrides included. The
GEMINI_API_KEY fallback is preserved for installs that exported the
variable and never ran `argus init`, and deliberately stops there: once
providers are configured, a model id resolving to none of them is
reported as such rather than quietly connected to Gemini.

The injected per-Session factory on the daemon Context is untouched, as
are the four test files that substitute it.

Refs #69, ADR 0020.
`max_output_tokens` on a `type: gemini` entry travelled all the way into a
Spec and was then dropped on the floor by the factory's gemini case, which
accepts neither a base URL nor a ceiling. A config key that silently does
nothing is the same bug the key exists to prevent: the operator gets a
Report truncated mid-write — a mutilated file rather than an error — and
nothing connects that symptom back to the line they wrote.

factory.New now rejects a non-zero MaxOutputTokens on a gemini Spec, placed
alongside the existing negative-ceiling check so all Spec validation stays
in one gate ahead of the switch. The error names both halves the operator
needs, the key to delete and the entry to delete it from. The field is new
and unreleased, so refusing it breaks no existing configuration, and zero
keeps meaning "send no ceiling" and stays valid for every type.

`url` on a gemini entry is deliberately left as it was — accepted and
ignored, not refused. It predates the factory, so configurations already
carry it, and an endpoint that is never read corrupts nothing on its way
past. Only its doc comment changes, to say plainly that gemini ignores it
and why the two fields are treated differently.

Implementing the ceiling inside the Gemini client is out of scope: the
field is scoped to the compatible Provider, and pkg/provider/gemini is
untouched.

ADR 0020 loses two claims that were written before the code existed. The
factory is in pkg/provider/factory, not pkg/provider — both implementation
packages import pkg/provider for the Request/Response types, so a switch
over them cannot live there without an import cycle; Spec and the type
constants do stay in pkg/provider, which is the coupling point the record
was actually making. And Spec carries five fields, not four.

Everything else the ADR asserts was checked against the code and left
alone: the error naming the supported types, the two supported types with
`ollama` gone from the reserved list, both construction sites going through
the factory with no concrete implementation imported outside it, the
one-method Provider interface unchanged, the hand-written client's two
endpoints and absent retry logic, and the tolerance cases enumerated in the
consequences.

The LLM Provider glossary entry named the factory but never Spec, the term
the ADR's coupling argument turns on; one sentence now says what it is and
why the Provider abstraction owns it rather than the config format.

Refs #69, ADR 0020.
… from the configured Providers

`argus doctor` gains one `llm provider` row that verifies the Provider backing
`default_model` on the operator's own machine, against their own model, at the
moment they configure it (ADR 0020). Argus implements a wire protocol and
certifies nobody's server, so this is where verification actually happens —
and what it turns "Argus is broken" into is "your model does not meet Argus's
requirements".

Two stages, in this order, mirroring the existing injected-probe pattern
(`GitHubMint`, `FrontDoorProbe`) so the network calls live in `cmd` and
`pkg/doctor` stays pure:

  1. `GET /models` — one request settling reachability, key validity and the
     existence of the configured model id, before a single token is spent: a
     typo in a model id has to surface before it costs anything.
  2. One minimal generation carrying a throwaway tool declaration, asserting a
     tool call actually arrives. Not redundant with the first: servers that
     accept tool declarations and then ignore them — the common local
     small-model failure — pass stage 1 and fail here, and that is the case
     documentation alone can never cover.

Each stage leaves its own hint on the row, the way `githubCheck` reports a
multi-stage result, because "endpoint unreachable", "model not served",
"generation refused" and "model ignores tools" send an operator to four
different fixes. A refusal of the tool declarations arrives already translated
by the adapter into a plain statement that the model does not support tool
calling, with the server's own words preserved after it, so the row names the
stage and gets out of the way rather than wrapping a verdict in a vaguer one —
and the stage is named "generation", not "tool calling", because a rate limit
or a 5xx arrives through the same return and the adapter deliberately refuses
to call either a missing capability. A `gemini` entry is reported as not
probed, with the reason: its client offers no model listing, and its tool
calling was never the unknown the probe exists for.

The API key check is now derived from the configured Providers instead of a
hardcoded `GEMINI_API_KEY` literal at `SeverityRequired`. That literal made
`argus doctor` fail, blockingly, for every operator configured only with
`openai-compatible` — for a key they correctly do not have, and which a
local-runtime user has none of at all. One row per Provider: an absent key is
non-blocking for a type that authenticates nobody, still blocking for `gemini`,
and a *declared* key that does not resolve blocks for either, because provider
construction hard-errors on it.

The `argus.yaml` row also stops passing a config no Session can start: a
`default_model` that resolves to none of the configured Providers, or to
several, is now a blocking failure carrying config's own message. Without it
the probe row simply went missing in exactly that case, and doctor reported a
ready environment.
Two places where the documentation named behaviour the code does not have.

`max_output_tokens` is refused outright on a `type: gemini` entry — the
Gemini client accepts no output ceiling, and silently ignoring the key
would reproduce the truncated Report the key exists to prevent. The guide
presented it as a plain optional setting, so the table row, the prose and
the key reference now carry the restriction as a property of the key, as
does the field's doc comment. `url` gets the same treatment for the
opposite behaviour: it is resolved and then dropped on a gemini entry, so
an operator setting it for proxy egress today gets no feedback at all.

The `Skill` entry claimed "RBAC is enforced at the Tool layer, not in the
skill". There is no Role anywhere in pkg/tool, and the `Role` entry
already records that the shared registry carries no Role and that closing
the gap is unbuilt. The non-escalation conclusion is true and survives,
re-grounded on the reason that holds today: a skill's whole effect is
markdown entering the conversation, and the callable surface is assembled
by the Session and its Channel, which no skill contributes to. Tool-layer
gating is named as the destination and marked not built, and the
"analyst+ capability" line now agrees instead of asserting a gate no code
applies — flagging the conflict with ADR 0005's RBAC section.

Documentation only: no behaviour changes, and no new enforcement proposed.

Refs #69
The URL field doc sold itself as an override for self-hosted or proxy
deployments without noting that the Gemini client takes no endpoint, so a
url on a gemini entry is resolved and then dropped. An operator who set it
to route egress through a proxy they control does not get that, and nothing
says so at runtime — worth stating in a product about security.

Records why this is dropped rather than refused, unlike max_output_tokens:
an endpoint that is never read corrupts nothing, where a dropped output
ceiling silently mutilates a Report.
The doctor slice left three type-dependent decisions outside pkg/provider: an
applicability test in cmd/doctor.go, a type switch in providerCheck, and a
local providerRequiresAPIKey in pkg/doctor. That made factory.go's package doc
false — adding a protocol had come to mean touching pkg/provider, the factory,
pkg/doctor and cmd — which is the same defect class this issue exists to fix, so
the code moves rather than the sentence.

Three questions about a Provider type now live next to the type constants, each
documented with why the answer is what it is, per type:

  - provider.RequiresAPIKey — gemini authenticates every request; a local
    openai-compatible runtime authenticates nobody.
  - provider.SupportsCapabilityProbe — openai-compatible serves GET /models and
    reaches servers nobody has verified; Gemini offers no listing and its tool
    calling was never the unknown the probe exists for.
  - provider.IsKnownType — tells "cannot be probed" apart from "is not a type at
    all", which are the same silence and different findings.

pkg/doctor and cmd/doctor.go ask instead of switching on type strings. Behaviour
is unchanged by construction: no user-visible string moved, the severity matrix
and the set of rows are identical, and the existing tests in
pkg/doctor/provider_test.go, pkg/doctor/doctor_test.go and cmd/doctor_test.go
pass untouched.

The guide's "Verifying your endpoint" section promised more than the probe
delivers, on three counts now stated honestly: the model id check is against
the listing, and an endpoint listing nothing leaves the id unverified rather
than convicted; an endpoint serving /chat/completions but not /models fails the
probe blockingly, recorded as the cost of enumerating before spending tokens
(ADR 0020) with the report route the failing hint already asks for; and only
openai-compatible providers are probed at all, said up front.

Refs #69
New's switch and provider.IsKnownType are the same vocabulary in two
places, and the import cycle that put the switch in pkg/provider/factory
leaves no way to derive one from the other. Both package docs say to treat
them as one edit; nothing noticed when somebody didn't.

The failure is quiet in both directions. A type in the switch but not in
IsKnownType constructs a working Provider that argus doctor then calls
unimplemented; a type in IsKnownType but not the switch passes every doctor
check and fails at the first Session, in the one place the factory exists
to stop failing. Verified by forcing both reds.

Also records in ADR 0020 the cost of the probe's own ordering: enumerating
/models before spending tokens means a server that serves /chat/completions
without /models fails argus doctor blockingly, though Argus would run
against it fine. The decision stands; the record now says what it costs.
@davideimola
davideimola merged commit fe74b79 into main Jul 30, 2026
3 checks passed
@davideimola
davideimola deleted the feat/openai-compatible-provider branch July 30, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAI-compatible LLM Provider

1 participant