feat(providers): ModelSession interface + AnthropicSession adapter#437
feat(providers): ModelSession interface + AnthropicSession adapter#437dimakis wants to merge 4 commits into
Conversation
Provider-agnostic abstraction for the agentic loop. ModelSession.turn() replaces the Agent SDK's query() — Mitzo owns the loop, ModelSession handles a single LLM call with streaming events. AnthropicSession speaks Anthropic Messages API format, pointed at praxis-proxy (127.0.0.1:9090) which translates to any backend provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 7 issue(s) (3 warning).
packages/harness/src/providers/anthropic-session.ts
Clean interface design with good SSE edge-case coverage. Main concerns: the thinking/max_tokens comment contradicts the code (potential API bug), unchecked type assertion on parsed SSE events, and constructor tests that don't verify the routing they claim to test.
- 🟡 bugs (L105): Comment says "Anthropic requires removing max_tokens" when thinking is enabled, but the code never removes
max_tokensfrom the body. Either the code is missingdelete body.max_tokens(real bug), or the comment is wrong and the current Anthropic API accepts both (misleading comment). As-is, this will either send an invalid request when thinking is used, or confuse future maintainers.[fixable] - 🟡 unsafe_assumptions (L57):
parsed as StreamEventis an unchecked type assertion. If the API sends a payload with a matchingtypefield but missing required properties (e.g., amessage_startwithoutmessage.usage), it passes through and will crash downstream. Consider a lightweight shape check (e.g., verifyparsed.messageexists formessage_start).[fixable] - 🔵 unsafe_assumptions (L78): API key silently falls back to
'dummy'when neitheroptions.apiKeynorANTHROPIC_API_KEYis set. Requests to the real Anthropic API will fail with a confusing 401 instead of a clear configuration error. Consider throwing or logging a warning when routing to the real API without a key.[fixable]
packages/harness/__tests__/anthropic-session.test.ts
Clean interface design with good SSE edge-case coverage. Main concerns: the thinking/max_tokens comment contradicts the code (potential API bug), unchecked type assertion on parsed SSE events, and constructor tests that don't verify the routing they claim to test.
- 🟡 missing_tests: Constructor tests for praxis proxy (line 61) and explicit baseUrl (line 68) only assert
session.provider === 'anthropic', which is true for every AnthropicSession regardless of config. They don't verify the actual URL used. The 'sends correct request body' test covers URL indirectly, but these two constructor tests are effectively no-ops — they prove the constructor doesn't throw, not that proxy/URL routing works.[fixable] - 🔵 missing_tests: No test for the
thinkingconfig path. Whenconfig.thinkingis set, the request body should include the thinking block. This is the only untested branch inturn()'s body construction, and it interacts with the max_tokens issue noted in the implementation.[fixable] - 🔵 missing_tests:
createAnthropicSessionfactory function is exported in the public API but has no test coverage. A single test calling it and verifying the return type would suffice.[fixable] - 🔵 missing_tests: No test for fetch-level failures (network error where
fetchitself rejects, vs. the tested HTTP 429 case wherefetchresolves with an error status). The error path would surface differently — an unhandled rejection vs. a thrown 'Anthropic API error' message.[fixable]
| body.tools = this.config.tools; | ||
| } | ||
|
|
||
| if (this.config.thinking) { |
There was a problem hiding this comment.
🟡 bugs: Comment says "Anthropic requires removing max_tokens" when thinking is enabled, but the code never removes max_tokens from the body. Either the code is missing delete body.max_tokens (real bug), or the comment is wrong and the current Anthropic API accepts both (misleading comment). As-is, this will either send an invalid request when thinking is used, or confuse future maintainers. [fixable]
| parsed.type === 'content_block_stop' || | ||
| parsed.type === 'message_delta' | ||
| ) { | ||
| return parsed as StreamEvent; |
There was a problem hiding this comment.
🟡 unsafe_assumptions: parsed as StreamEvent is an unchecked type assertion. If the API sends a payload with a matching type field but missing required properties (e.g., a message_start without message.usage), it passes through and will crash downstream. Consider a lightweight shape check (e.g., verify parsed.message exists for message_start). [fixable]
|
|
||
| const useProxy = options.useProxy ?? !!process.env.MITZO_USE_PRAXIS; | ||
| this.baseUrl = options.baseUrl ?? (useProxy ? PRAXIS_URL : DEFAULT_BASE_URL); | ||
| this.apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? 'dummy'; |
There was a problem hiding this comment.
🔵 unsafe_assumptions: API key silently falls back to 'dummy' when neither options.apiKey nor ANTHROPIC_API_KEY is set. Requests to the real Anthropic API will fail with a confusing 401 instead of a clear configuration error. Consider throwing or logging a warning when routing to the real API without a key. [fixable]
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 5 issue(s) (1 critical) (2 warning).
packages/harness/src/providers/anthropic-session.ts
The thinking-mode path has a documented-but-unimplemented requirement (max_tokens must be removed), which will cause API errors when extended thinking is used. Otherwise a clean, well-tested adapter with good SSE chunking coverage.
- 🔴 bugs (L105): The comment on line 108 documents that
max_tokensmust be removed whenthinkingis enabled, but the code never does it.max_tokens(set on line 91) remains in the request body alongsidethinking, which will cause an Anthropic API error. Adddelete body.max_tokens;inside theif (this.config.thinking)block.[fixable] - 🟡 unsafe_assumptions (L78): Falling back to
'dummy'when no API key is provided will silently send requests that return 401. Consider throwing at construction time (or at least logging a warning) when no key is available andbaseUrlpoints to the real Anthropic API, so misconfiguration is caught early rather than at the firstturn()call.[fixable]
packages/harness/__tests__/anthropic-session.test.ts
The thinking-mode path has a documented-but-unimplemented requirement (max_tokens must be removed), which will cause API errors when extended thinking is used. Otherwise a clean, well-tested adapter with good SSE chunking coverage.
- 🟡 missing_tests: No test for the
thinkingconfig path. Given the bug above (max_tokens not removed), a test asserting the correct request body whenthinkingis enabled would have caught it. Should verifybody.thinkingis set andbody.max_tokensis absent.[fixable] - 🔵 missing_tests: No test for AbortSignal cancellation (
config.signal). A test that aborts mid-stream and verifies the reader is released would cover an important edge case for the agentic loop's interrupt flow.[fixable] - 🔵 style (L62): Constructor tests for praxis proxy (line 62) and explicit baseUrl (line 69) only assert
provider === 'anthropic'— they don't verify the resolved URL. Consider making aturn()call and checkingfetchSpy.mock.calls[0][0]to confirm the URL resolution logic actually works, or expose the resolved URL for testing.[fixable]
| body.tools = this.config.tools; | ||
| } | ||
|
|
||
| if (this.config.thinking) { |
There was a problem hiding this comment.
🔴 bugs: The comment on line 108 documents that max_tokens must be removed when thinking is enabled, but the code never does it. max_tokens (set on line 91) remains in the request body alongside thinking, which will cause an Anthropic API error. Add delete body.max_tokens; inside the if (this.config.thinking) block. [fixable]
|
|
||
| const useProxy = options.useProxy ?? !!process.env.MITZO_USE_PRAXIS; | ||
| this.baseUrl = options.baseUrl ?? (useProxy ? PRAXIS_URL : DEFAULT_BASE_URL); | ||
| this.apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? 'dummy'; |
There was a problem hiding this comment.
🟡 unsafe_assumptions: Falling back to 'dummy' when no API key is provided will silently send requests that return 401. Consider throwing at construction time (or at least logging a warning) when no key is available and baseUrl points to the real Anthropic API, so misconfiguration is caught early rather than at the first turn() call. [fixable]
| }); | ||
|
|
||
| it('uses praxis proxy when MITZO_USE_PRAXIS is set', () => { | ||
| vi.stubEnv('MITZO_USE_PRAXIS', '1'); |
There was a problem hiding this comment.
🔵 style: Constructor tests for praxis proxy (line 62) and explicit baseUrl (line 69) only assert provider === 'anthropic' — they don't verify the resolved URL. Consider making a turn() call and checking fetchSpy.mock.calls[0][0] to confirm the URL resolution logic actually works, or expose the resolved URL for testing. [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 7 issue(s) (3 warning).
packages/harness/src/providers/anthropic-session.ts
Solid foundation for provider-agnostic sessions. Main concerns: a misleading comment in the thinking config path that could cause a future bug, a silent fallback to a dummy API key that masks misconfiguration, and missing test coverage for the thinking and abort-signal code paths.
- 🟡 bugs (L107): Misleading comment will cause a future bug. The comment says "Anthropic requires removing max_tokens" when thinking is enabled, but the code doesn't do it — and shouldn't, because the API actually requires
max_tokensas the total output budget alongsidethinking.budget_tokens. A future contributor following this comment's guidance would break thinking mode. Either delete the comment or correct it to explain the relationship (max_tokens = total budget, budget_tokens = thinking portion).[fixable] - 🟡 unsafe_assumptions (L82): API key falls back to the literal string
'dummy'when neitheroptions.apiKeynorANTHROPIC_API_KEYis set. This silently sends requests with an invalid key, producing a confusing 401 instead of failing fast at construction time. Consider throwing an error if no key is available (or at minimum logging a warning), so misconfigured sessions are caught early.[fixable] - 🔵 style (L1): The 10-line file-level doc comment is verbose for a project whose CLAUDE.md says "default to writing no comments" and "never write multi-paragraph docstrings or multi-line comment blocks." The module's purpose is already clear from the class name and file name. Consider reducing to a single line or removing entirely.
[fixable] - 🔵 bugs (L158): SSE parser resets
currentEventTypeto''immediately after processing adata:line, which means multi-linedata:fields (valid per SSE spec — concatenated with newlines) would lose their event type context on the second line. Anthropic's API doesn't use multi-line data fields today, so this is theoretical, but if praxis-proxy emits them the parser will silently drop events.[fixable]
packages/harness/__tests__/anthropic-session.test.ts
Solid foundation for provider-agnostic sessions. Main concerns: a misleading comment in the thinking config path that could cause a future bug, a silent fallback to a dummy API key that masks misconfiguration, and missing test coverage for the thinking and abort-signal code paths.
- 🟡 missing_tests: No test for the
thinkingconfig path. The implementation conditionally setsbody.thinking(line 105-109 of anthropic-session.ts) but this branch is never exercised in tests. A test should verify that whenthinking: { type: 'enabled', budget_tokens: 1000 }is set, the request body includes the thinking block.[fixable] - 🔵 missing_tests: No test for AbortSignal cancellation.
ModelSessionConfig.signalis passed through tofetch, but no test verifies that aborting mid-stream cancels the request and propagates the error. This is a key production path for session interruption.[fixable] - 🔵 missing_tests: No test for network-level failures (fetch rejection). The only error test covers HTTP 429 responses. A test where
fetchitself throws (e.g.,ERR_CONNECTION_REFUSED) would verify the error propagation path for when the API host is unreachable.[fixable]
|
|
||
| if (this.config.thinking) { | ||
| body.thinking = this.config.thinking; | ||
| // When thinking is enabled, Anthropic requires removing max_tokens |
There was a problem hiding this comment.
🟡 bugs: Misleading comment will cause a future bug. The comment says "Anthropic requires removing max_tokens" when thinking is enabled, but the code doesn't do it — and shouldn't, because the API actually requires max_tokens as the total output budget alongside thinking.budget_tokens. A future contributor following this comment's guidance would break thinking mode. Either delete the comment or correct it to explain the relationship (max_tokens = total budget, budget_tokens = thinking portion). [fixable]
| this.apiVersion = options.apiVersion ?? '2023-06-01'; | ||
|
|
||
| log.info('session created', { | ||
| model: config.model, |
There was a problem hiding this comment.
🟡 unsafe_assumptions: API key falls back to the literal string 'dummy' when neither options.apiKey nor ANTHROPIC_API_KEY is set. This silently sends requests with an invalid key, producing a confusing 401 instead of failing fast at construction time. Consider throwing an error if no key is available (or at minimum logging a warning), so misconfigured sessions are caught early. [fixable]
| @@ -0,0 +1,207 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: The 10-line file-level doc comment is verbose for a project whose CLAUDE.md says "default to writing no comments" and "never write multi-paragraph docstrings or multi-line comment blocks." The module's purpose is already clear from the class name and file name. Consider reducing to a single line or removing entirely. [fixable]
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split('\n'); | ||
| // Keep the last incomplete line in the buffer | ||
| buffer = lines.pop() ?? ''; |
There was a problem hiding this comment.
🔵 bugs: SSE parser resets currentEventType to '' immediately after processing a data: line, which means multi-line data: fields (valid per SSE spec — concatenated with newlines) would lose their event type context on the second line. Anthropic's API doesn't use multi-line data fields today, so this is theoretical, but if praxis-proxy emits them the parser will silently drop events. [fixable]
…ve misleading comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 5 issue(s) (1 warning).
packages/harness/__tests__/anthropic-session.test.ts
Well-structured provider abstraction with solid SSE parsing and good test coverage of the happy path. Main gap is incomplete validation depth in parseSSE for message_delta/message_start usage fields, plus missing tests for thinking blocks, abort signals, and parse error paths.
- 🔵 missing_tests: No test coverage for
thinkingcontent blocks (content_block_startwithtype: 'thinking'andcontent_block_deltawithtype: 'thinking_delta'). These event types are defined insession-types.tsand wired throughparseSSEvalidation, but never exercised by tests.[fixable] - 🔵 missing_tests: No test for
AbortSignalcancellation.ModelSessionConfig.signalis passed through tofetch()but no test verifies that aborting mid-stream works correctly (reader cleanup, error propagation).[fixable] - 🔵 missing_tests: No test for
parseSSEreturning null on malformed data (invalid JSON, missing required fields, unknown event types). The function has significant validation logic that is only exercised on the happy path. A targeted unit test for parseSSE edge cases would strengthen confidence.[fixable]
packages/harness/src/index.ts
Well-structured provider abstraction with solid SSE parsing and good test coverage of the happy path. Main gap is incomplete validation depth in parseSSE for message_delta/message_start usage fields, plus missing tests for thinking blocks, abort signals, and parse error paths.
- 🔵 regressions (L98): Export gap:
providers/index.tsexports individual event types (MessageStartEvent,ContentBlockStartEvent,ContentBlockDeltaEvent,ContentBlockStopEvent,MessageDeltaEvent) but the main barrelsrc/index.tsdoes not re-export them. Consumers using@mitzo/harnesscan only access theStreamEventunion — if they need to type a handler for a specific event, they must import from the deep path. Either re-export them or explicitly omit them from providers/index.ts to signal intent.[fixable]
packages/harness/src/providers/anthropic-session.ts
Well-structured provider abstraction with solid SSE parsing and good test coverage of the happy path. Main gap is incomplete validation depth in parseSSE for message_delta/message_start usage fields, plus missing tests for thinking blocks, abort signals, and parse error paths.
- 🟡 bugs (L72):
parseSSEvalidatesmessage_deltaby checking onlyparsed.delta, but theMessageDeltaEventtype also requiresusage: { output_tokens: number }. If the API (or praxis-proxy) sends amessage_deltawithoutusage, the type assertion passes but downstream access toevent.usage.output_tokensthrows. The same applies tomessage_start—message.usageis required in the type but not validated. Addparsed.usagechecks for consistency with the other cases.[fixable]
| } from './providers/index.js'; | ||
| export { MODEL_COSTS, calculateCost } from './providers/index.js'; | ||
|
|
||
| // Agentic session — provider-agnostic streaming model interface |
There was a problem hiding this comment.
🔵 regressions: Export gap: providers/index.ts exports individual event types (MessageStartEvent, ContentBlockStartEvent, ContentBlockDeltaEvent, ContentBlockStopEvent, MessageDeltaEvent) but the main barrel src/index.ts does not re-export them. Consumers using @mitzo/harness can only access the StreamEvent union — if they need to type a handler for a specific event, they must import from the deep path. Either re-export them or explicitly omit them from providers/index.ts to signal intent. [fixable]
| default: | ||
| return null; | ||
| } | ||
| } catch { |
There was a problem hiding this comment.
🟡 bugs: parseSSE validates message_delta by checking only parsed.delta, but the MessageDeltaEvent type also requires usage: { output_tokens: number }. If the API (or praxis-proxy) sends a message_delta without usage, the type assertion passes but downstream access to event.usage.output_tokens throws. The same applies to message_start — message.usage is required in the type but not validated. Add parsed.usage checks for consistency with the other cases. [fixable]
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
ModelSessioninterface — provider-agnostic abstraction for the agentic loop (replaces Agent SDK'squery())ModelSession.turn(messages)handles one LLM call, streams back normalizedStreamEvents — Mitzo owns the loopAnthropicSessionimplementation speaks Anthropic Messages API format, routes through praxis-proxy on:9090for provider translationStreamEvent,ConversationMessage,ContentBlock,ToolDefinition,ModelSessionConfig@mitzo/harnessfor server consumptionArchitecture:
This is Phase 1a of the provider-agnostic architecture (mgmt#215). Next: build the agentic loop that calls
ModelSession.turn()repeatedly, replacing the SDK'squery().Test plan
tsc --noEmit):9090🤖 Generated with Claude Code