feat!: an OpenAI-compatible LLM Provider - #78
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #69.
Argus spoke to exactly one LLM Provider.
argus.yamlalready accepted atypefield and already documented
openaiandollamaas reserved values, butnothing in the codebase selected an implementation from it — the daemon's
Provider factory constructed a Gemini client unconditionally, so
type: openaisilently 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, neveropenai— because Argus implements awire 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 capabilityprobe in
argus doctorthat verifies on the user's machine, against the user'smodel, at the moment they configure it. The compatibility table is
community-fed: the artifact a contributor pastes into a PR is their
argus doctoroutput.BREAKING CHANGE
The USD cost figure is gone; token counts stay.
pkg/budgetis deleted in full,and
cost_usdis removed from the UDS usage frame, the TUI status line, the/costreadout, the chat client and theargus initinterview.Of the three layers
pkg/budgetpromised, two — the per-session token cap andthe 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.ProtocolVersionis bumped to 2 accordingly. JSON tolerates the absent field,which is exactly the hazard: a stale v1 client would render the missing
cost_usdas$0.0000. The version gate turns that into "update the argusbinary" 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-writtennet/http+encoding/jsonclient, 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-standardfinish_reason, empty orduplicated 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
httpteststand-in endpoint.pkg/provider/factory— one place that knows which implementations exist.Neither construction site (the daemon, the
argus initinterview) imports aconcrete implementation any more.
Specand the type constants live inpkg/provider; only the switch sits one directory below, because everyimplementation imports
pkg/providerand a switch over them there wouldcycle.
provider-name/model-id, split on the first slash only, so an OpenRouterid 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.
max_output_tokens, omitted by default. It is refused on ageminientry rather than ignored: the Gemini client accepts no ceiling, and alever that quietly does nothing delivers the same truncated Report it exists to
prevent.
argus doctorGET /modelsfirst — settling reachability, key validityand 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.
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:
Toolsis sent on every turn, so a status-only rule would label aturn-30 context overflow "your model cannot call tools".
hardcoded
GEMINI_API_KEYcheck atSeverityRequired, so anopenai-compatible-only operator failedargus doctorblockingly for a keythey correctly do not have.
unresolvable
default_modelgave✓ argus.yamland exit 0 for a config noSession can start.
argus initand pinned in a test with its exact path. This is where the protocol's
sharpest footgun lives: the
/v1suffix is mandatory, Groq does not serve atthe domain root, and DeepSeek does — so the custom-URL validator deliberately
does not require
/v1.would have blocked a local-runtime user at the first screen.
OPENAI_API_KEY/OPENAI_BASE_URLpre-fill from.envor the shell.providerEnvVarneeded an explicit case: the fallback rule would have emittedOPENAI-COMPATIBLE_API_KEY, and a test now pins that every offered type'svariable names are shell-safe.
Documentation
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.
for and never defined.
viewerRole was described as gated by a "global budget cap"; no such cap everexisted. Four earlier ADRs (0002, 0004, 0008, 0018) leaned on it, ADR 0002
load-bearingly — it justified letting
viewerchat. All now say what isenforced, and that nothing bounds spend.
Reviewing this
The three commits worth reading closely are
ecfdf17(the adapter and itstolerance tests),
d6cb8de(the factory and both call sites) and20dd5d1(theprobe and the key-check severity matrix). The four test files that substitute the
daemon's
NewProviderseam are byte-identical throughout — that is theregression signal that per-Session Provider acquisition is intact.
pkg/agent/context_floor_test.gois a growth guard, not a token count: itmeasures 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
channel;
Session.Usage()has no production caller, so Reviews via MCP andGitHub surface no token figure. Unchanged by this PR, and left alone because
closing it means touching three channels' surfaces.
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. Theglossary entry that contradicted the corrected
Roleentry is fixed here; therest is a separate issue.
start_review_localre-roots the file tools at any absolute directory thatexists, 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.