Skip to content

feat(generate): openai, anthropic and ollama provider adapters (WS1) - #2775

Open
devarismeroxa wants to merge 3 commits into
mainfrom
feat/ws1-provider-adapters
Open

feat(generate): openai, anthropic and ollama provider adapters (WS1)#2775
devarismeroxa wants to merge 3 commits into
mainfrom
feat/ws1-provider-adapters

Conversation

@devarismeroxa

Copy link
Copy Markdown
Contributor

The three Provider implementations behind the seam merged in #2772. Zero new dependencies, per design doc §1.

Adapter Implementation
openai Wraps the already-vendored sashabaranov/go-openai — the same client the openai built-in processor uses. Hand-rolling a second OpenAI HTTP client would be the YAGNI violation, not avoiding one
anthropic Thin net/http JSON client against the Messages API
ollama Thin net/http JSON client against a local server, using the same two-method Doer shape the ollama built-in processor already has — so the tree keeps one hand-rolled-client pattern instead of forking a second

Decisions worth stating

Token counts are only ever what the provider reported. A 0 means it reported nothing. Nothing is estimated — an estimated count is indistinguishable from a real one in --json output users may bill against.

Every call is bounded (60s default). Generation is interactive — a human is watching a spinner — so an unbounded wait on a wedged provider is worse than a clear failure they can retry. Applied per attempt, not to the whole retry budget.

Anthropic concatenates every text block rather than taking the first. A split response is valid, and taking only the first would silently truncate the config — which would then fail validation for a reason unrelated to the config.

Reachable() deliberately does not check whether a model is loaded. Pulling a model is the user's job, and failing that belongs in the completion call's error where it can say so — not in a silent exclusion from auto-detection that leaves them with "no provider configured" on a machine that plainly has one.

Errors carry the provider name and HTTP status, never a stack trace. A trace from inside an HTTP client tells a user nothing actionable. Error bodies are bounded to 512 bytes and flattened to one line, so an HTML error page can't dump itself into a terminal.

Adversarial self-review

  1. A mutation survived, and the reason mattered. "Treat non-2xx as success" killed nothing: the test asserted the error code and provider name, both of which still held — with status checking removed, the error body decodes to empty content and yields the same provider_error. The assertion passed for the wrong reason, while the user would be told "empty response" instead of "HTTP 401". The test now asserts the status appears in the message; the mutation is killed.
  2. Two test handlers deadlocked the package for the full 10-minute timeout. Blocking on r.Context().Done() deadlocks against httptest.Server.Close: the cleanup waits for the handler, the handler waits for a cancellation the cleanup would have caused. Both waits are now bounded.
  3. I pushed before reading lint output. depguard requires goccy/go-json over encoding/json. Fixed in a follow-up commit, and called out as such rather than folded in silently.

Tests

httptest-based — no network, no API keys — and they assert what actually went on the wire (headers, body, path), not the struct tags.

Mutation Test killed
Anthropic takes only the first text block ConcatenatesTextBlocks
Drop the mandatory anthropic-version header HappyPath
Allow an empty ollama response EmptyResponseIsAnError
Estimate tokens instead of reporting HappyPath
Treat non-2xx as success HTTPErrorsAreProviderErrors
Enable ollama streaming HappyPath

Also covered: context cancellation aborts promptly, the per-call timeout bounds a wedged provider, and --model precedence over the adapter default.

Risk tier

3. Leaf package, no call sites yet, no data path, no new dependencies.

Roadmap

v0.20 WS1 (conduit generate), design doc §1. Next: the generation loop (§3) — grounded prompt → candidate → validate.RunBytes gate → semantic-intent check → preview.

🤖 Generated with Claude Code

https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

devarismeroxa and others added 2 commits August 5, 2026 16:35
The three Provider implementations behind the seam merged in #2772. Zero new
dependencies, per design doc §1:

- openai   wraps the ALREADY-VENDORED sashabaranov/go-openai — the same client
           the `openai` built-in processor uses. Hand-rolling a second OpenAI
           HTTP client would be the YAGNI violation, not avoiding one.
- anthropic thin net/http JSON client against the Messages API.
- ollama    thin net/http JSON client against a local server's /api/generate,
           the same two-method Doer shape the `ollama` built-in processor
           already uses, so the tree keeps a single hand-rolled-client pattern
           rather than forking a second.

Decisions worth stating:

Token counts are only ever what the provider REPORTED. A 0 means it reported
nothing; nothing is estimated. An estimated count would be indistinguishable
from a real one in --json output that users may bill against.

Every call is bounded by DefaultTimeout (60s). Generation is interactive — a
human is watching a spinner — so an unbounded wait on a wedged provider is
worse than a clear failure they can retry. The retry loop applies this per
attempt, not to the whole budget.

Anthropic responses concatenate every text block rather than taking the first.
A response split across blocks is valid, and taking only the first would
silently truncate the generated config — which would then fail validation for
a reason that has nothing to do with the config.

Ollama sets stream:false. Nothing renders tokens as they arrive, so streaming
would only mean parsing a JSONL body for no benefit.

Reachable() probes with HEAD and accepts any status < 500, deliberately NOT
checking whether a model is loaded. Pulling a model is the user's job, and
failing that belongs in the completion call's error where it can say so — not
in a silent exclusion from auto-detection that leaves them with
"no provider configured" on a machine that plainly has one.

Provider errors carry the provider name and the HTTP status, never a stack
trace: a user seeing this needs to know which provider failed and why, and a
trace from inside an HTTP client tells them nothing actionable. Error bodies
are bounded to 512 bytes and flattened to one line so an HTML error page cannot
dump itself into a terminal.

Tests are httptest-based (no network, no keys) and assert what actually went on
the wire — headers, body, path — rather than trusting struct tags.

Every mutation verified:

- anthropic taking only the first text block -> ConcatenatesTextBlocks
- dropping the mandatory anthropic-version header -> HappyPath
- allowing an empty ollama response -> EmptyResponseIsAnError
- estimating tokens instead of reporting -> HappyPath
- treating non-2xx as success -> HTTPErrorsAreProviderErrors
- enabling ollama streaming -> HappyPath

The non-2xx mutation initially SURVIVED. The test asserted the error code and
provider name, both of which still held: with status checking removed the error
body decodes to empty content and yields the same provider_error, so the
assertion passed for the wrong reason while the user would be told "empty
response" instead of "HTTP 401". The test now asserts the status appears in the
message, and the mutation is killed.

Two httptest handlers initially blocked on r.Context().Done(), which deadlocks
against httptest.Server.Close — the cleanup waits for the handler, the handler
waits for a cancellation the cleanup would have caused. The package hung for
the full 10-minute timeout. Both waits are now bounded.

Risk tier: 3. Leaf package, no call sites yet, no data path, no new deps.

Roadmap: v0.20 WS1 (`conduit generate`), design doc §1. Next: the generation
loop (§3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
The repo's depguard list requires github.com/goccy/go-json over encoding/json.
No behaviour change — same struct tags, same decoding.

I pushed before reading the lint output on the previous commit; this is that
output being addressed rather than a second attempt at the same thing.
@devarismeroxa
devarismeroxa requested a review from a team as a code owner August 5, 2026 23:39
The retry loop feeds a failed candidate's findings back into the next prompt,
and that text is partly derived from the MODEL'S OWN PRIOR OUTPUT — finding
messages quote the config paths, connector IDs, and values the candidate
contained. The design doc calls this out as two distinct hazards and makes
bounding it a testable requirement rather than a soft guideline:

  (a) prompt growth — a candidate with dozens of findings balloons every
      retry's payload;
  (b) a channel by which model-originated text, including any injected
      instruction the first candidate echoed, re-enters the next prompt.

So feedback is constructed from TYPED FIELDS (Code, ConfigPath, Suggestion) and
hard-bounded, never concatenated report prose. The finding's free-form Message
— which is exactly where candidate-derived text lives — never reaches the
prompt.

Bounds: at most 10 findings per retry, each value clipped to 120 characters and
flattened to a single line. Flattening matters as much as clipping: a value
containing "\n\nIgnore previous instructions" would otherwise render as its own
line in the retry prompt, visually indistinguishable from the instructions this
package wrote.

Truncation is stated in the prompt rather than hidden. A model told "fix these
10" when there are 40 will confidently return something still broken.

Only error-severity findings are fed back. Spending a retry "fixing" a lint
warning wastes a call and risks introducing a real error.

WithConnectorSuggestions is the retry loop's consumer of fuzzymatch (§7):
"connector `postgre` does not exist; did you mean `postgres`?" turns a
hallucination into a self-correction inside the retry budget instead of a
terminal failure. Suggestions come only from the known catalog, so nothing
model-originated is ever echoed back as if it were a valid name.

Tests, every mutation verified:

- echoing the finding Message -> NeverEchoesRawProse (the injected-instruction
  test)
- removing the findings cap -> IsCapped
- dropping newline flattening -> ClipsAndFlattensValues + NeverEchoesRawProse
- feeding back warnings -> OnlyErrorSeverity
- skipping the sort -> Deterministic
- inventing a suggestion when nothing matches -> ConnectorSuggestions
- hiding truncation from the prompt -> IsCapped

Two of those did not kill on the first attempt and the tests were the problem,
not the mutations. The determinism test re-rendered one already-ordered report,
which passes with or without a sort — it proved stability, not ordering. It now
feeds deliberately unsorted findings across two files and asserts the output
order. The truncation mutation initially failed to apply at all (shell
escaping), so its "0 killed" was meaningless; re-run correctly, it kills.

Risk tier: 3. Leaf package, no call sites yet, no data path.

Roadmap: v0.20 WS1, design doc §3. Next: the loop itself wiring
provider.Complete -> parse -> validate.RunBytes -> semantic check -> preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
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.

1 participant