mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## Summary Wire the advisor runtime into `chatd`: read the admin config on every `runChat`, gate tool registration and system-prompt guidance on a **single eligibility boolean**, register the `advisor` built-in tool, and apply the exclusive-tool policy from PR 1. ## Motivation This is the integration seam where PRs 1–3 come together into an actual user-visible feature. Gating is deliberately root-chat-only for the initial rollout; child/sub-agent chats still do not see the tool or the guidance block. ## Changes ### `coderd/x/chatd/chatd.go` - `loadAdvisorConfig(ctx, logger)` reads the admin config (from PR 3) on each run. If `ModelConfigID` is set, it resolves the override model via `configCache.ModelConfigByID`; otherwise it falls back to the outer chat's model and provider options. Reasoning effort is plumbed into provider options via `applyAdvisorReasoningEffort`. - One computed `advisorEligible` boolean drives **both** tool registration (after skill tools, before MCP tools) and guidance injection via `chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)`. - `setAdvisorPromptSnapshot` closures capture the outer prompt state at the right points in the lifecycle (`renderPlanPathPrompt`, `ReloadMessages`, `PrepareMessages`) so the advisor handoff uses the same context the outer model saw. - `ExclusiveToolNames["advisor"] = true` is passed to `chatloop.Run()` so mixed batches are rejected cleanly (PR 1 machinery). - `builtinToolNames["advisor"] = true` so metrics keep advisor distinct from the generic `mcp` label. ### Child-chat guard - Child/sub-agent chats deliberately do not see the advisor tool or guidance block, to avoid recursion/cost blowups until the pattern is proven. This is covered by `TestAdvisorGating_ChildChat` (currently skipped pending a rewrite against the new `plan`/`explore` subagent infrastructure; core gating logic is still exercised by `TestAdvisorGating_Disabled` and `TestAdvisorGating_RootChat`). ## Stack context This is **PR 4 of 6** in the advisor feature stack. It depends on PRs 1–3. ## Scope / non-goals - No frontend changes. The feature is invocable via the backend but renders generically until PR 5. - No separate provider runner; the nested advisor call reuses the existing model/provider path. - No DB migration. ## Validation - `go test ./coderd/x/chatd/... -run TestAdvisor` - `go build ./...` - `make lint` --- <details> <summary>📋 Implementation Plan (shared across the advisor stack)</summary> # Plan: Add a Mux-style advisor tool to coder agents/chatd ## Outcome Add a first-class `advisor` tool to agent chats in `coderd/x/chatd` that feels native to Coder: - it is a built-in server-side tool, not an MCP/dynamic-tool workaround; - it performs a nested **tool-less** model call for strategic advice; - it is exposed only when eligible, and the prompt mentions it only when it is actually available; - it is treated as a **planning-only** tool so it does not run alongside action tools in the same batch; - it tracks usage/cost separately enough for operators to reason about it; - it has a minimally polished UI in the Agents page; - and it ships with explicit dogfooding evidence, including screenshots and repro videos. ## Design decisions to lock before coding 1. **Primary architecture:** native built-in tool in `chattool/`, backed by a small `chatadvisor` package. 2. **Nested model execution:** reuse chatd's existing model/provider stack for a one-step, tool-less advisor call rather than inventing a new provider pathway. 3. **Execution policy:** treat `advisor` as an exclusive/planning-only tool; mixed batches must return structured policy errors and force the model to retry cleanly. 4. **Availability:** initial rollout is for root agent chats only; disable for child/sub-agent chats until recursion/cost policy is proven. 5. **Prompt sync:** use one eligibility boolean to drive both tool registration and advisor guidance injection. 6. **Persistence/cost split:** MVP should keep advisor usage visible in result metadata and server metrics; only add DB schema if product/billing explicitly needs queryable advisor-specific cost. 7. **UI scope:** generic tool rendering is an acceptable temporary milestone during backend bring-up, but the release candidate should include a dedicated lightweight advisor renderer. ## Delivery model The work should be executed as coordinated workstreams with one integration owner and parallel contributors for low-conflict areas. The integration owner should own `coderd/x/chatd/chatd.go` because prompt assembly, tool registration, and model resolution all converge there. ## Detailed workstreams ### Repo evidence used for this plan <details> <summary>Mux reference and current chatd seams</summary> **Mux reference implementation** - `src/node/services/tools/advisor.ts` — native advisor tool implementation. - `src/common/constants/advisor.ts` — advisor prompt/constants and truncation policy. - `src/common/utils/tools/tools.ts` — conditional tool registration. - `src/node/services/streamContextBuilder.ts` — injects advisor guidance only when the tool is available. **Current chatd seams** - `coderd/x/chatd/chatd.go` - `processChat()` — tool assembly, prompt assembly, and chatloop invocation. - `resolveChatModel()` — current model/provider/key resolution seam. - `type Config struct` — server-level chatd configuration surface. - `coderd/x/chatd/chatloop/chatloop.go` - `Run()` — main streaming/model loop. - `executeTools()` — built-in tool execution/batching seam. - `coderd/x/chatd/chattool/` — built-in tool implementations. - `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx` — tool renderer dispatch. - `site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts` and `ConversationTimeline.tsx` — tool/result merge and rendering flow. </details> ### Workstream map and ownership | Workstream | Primary owner | Main files | Can run in parallel? | Done when | |---|---|---|---|---| | 0. Integration + gating | Integration lead | `coderd/x/chatd/chatd.go` | No; central merge lane | Tool registration, prompt sync, and model selection are wired together | | 1. Advisor runtime + tool | Backend agent | new `coderd/x/chatd/chatadvisor/`, new `coderd/x/chatd/chattool/advisor.go` | Yes | Tool can perform a tool-less advisor call in memory and return structured results | | 2. Planning-only execution policy | Chatloop agent | `coderd/x/chatd/chatloop/chatloop.go`, related tests | Yes | Mixed `advisor` + action-tool batches are rejected cleanly and deterministically | | 3. Metrics/usage/config | Backend/telemetry agent | `chatd.go`, `chatloop/metrics.go`, optional config plumbing | Partially; coordinate with integration lead | Advisor usage is separately visible in metadata/metrics and limits are enforced | | 4. Frontend rendering | Frontend agent | `site/.../tools/Tool.tsx`, new `AdvisorTool.tsx`, stories | Yes after result schema stabilizes | Advisor renders as a readable card and story tests pass | | 5. Dogfood + QA evidence | QA agent | dev server, Storybook, dogfood output | After backend + UI are usable | Repro videos, screenshots, and a concise QA report exist | ### Parallelization rules - **Do not split `coderd/x/chatd/chatd.go` across multiple execution agents without an integration lead.** That file owns prompt building, tool registration, model resolution, and cost persistence. - Workstreams 1 and 2 can be developed in parallel and then stacked onto the integration branch. - Workstream 4 should begin once the backend result schema is agreed on, even if the backend is still behind a feature flag. - Any agent that needs to re-check Mux behavior should clone `coder/mux` into a temporary directory (for example, `$(mktemp -d)/mux`) and inspect it read-only; do not vendor or copy code from Mux directly. ## Phase 0 — Preflight and guardrails ### Goals - Align the team on the smallest shippable architecture. - Prevent scope creep into MCP/dynamic-tool/sub-agent variants. - Decide upfront what is MVP vs. follow-up. ### Tasks 1. **Confirm the MVP boundary.** - Ship a built-in advisor tool first. - Do **not** make MCP, dynamic tools, or sub-agents the primary implementation. - Do **not** add transient streaming phases in the first backend PR unless they fall out almost for free. 2. **Confirm local workflow hygiene before coding.** - Ensure the repo is using the project git hooks from `scripts/githooks`. - Do not bypass hooks with `--no-verify`. - Use `./scripts/develop.sh` for the full dev server rather than manual build/run commands. 3. **Lock the model-selection policy.** - **Recommended MVP:** advisor uses the same resolved provider/model/cost config as the current chat, with advisor-specific max-output and usage caps. - **Follow-up only if required:** add a separate `AdvisorModelConfigID`-style override that resolves through the existing `configCache`/model-config path. Do not invent a new free-form `provider:model` parser if chatd already stores provider/model separately. 4. **Lock the persistence policy.** - **Recommended MVP:** no DB migration. Persist advisor-visible metadata in the tool result and record separate metrics in memory/Prometheus. - **Only if product/billing explicitly asks for queryable advisor cost:** add a later DB migration or usage table, following the normal `queries/*.sql` + `make gen` workflow. 5. **Create an execution ADR note in the work item or tracking doc.** - Capture: built-in tool, tool-less nested call, root-chat-only rollout, exclusive execution policy, MVP no-DB-migration default. ### Quality gate - Everyone on the team can state the same answers to these questions: - Is advisor a built-in tool? **Yes.** - Can advisor run with action tools in the same batch? **No.** - Does advisor get tools of its own? **No.** - Is a DB migration required for MVP? **No, unless billing insists.** ## Phase 1 — Build the advisor runtime and tool wrapper ### Goals Create the core advisor implementation in a way that is easy to test and keeps `chattool/` thin. ### Files to add - `coderd/x/chatd/chatadvisor/types.go` - `coderd/x/chatd/chatadvisor/guidance.go` - `coderd/x/chatd/chatadvisor/handoff.go` - `coderd/x/chatd/chatadvisor/runtime.go` - `coderd/x/chatd/chatadvisor/runner.go` - `coderd/x/chatd/chattool/advisor.go` ### Responsibilities by file 1. **`types.go`** - Define the input/result schema used by the tool and UI. - Keep the result shape close to Mux so the UI and model both have predictable cases. - Recommended result variants: - `advice` - `limit_reached` - `error` Recommended shape: ```go type AdvisorArgs struct { Question string `json:"question"` } type AdvisorResult struct { Type string `json:"type"` Advice string `json:"advice,omitempty"` Error string `json:"error,omitempty"` AdvisorModel string `json:"advisor_model,omitempty"` RemainingUses int `json:"remaining_uses,omitempty"` Usage *AdvisorUsageResult `json:"usage,omitempty"` } ``` 2. **`guidance.go`** - Hold two strings: - the nested advisor system prompt; - the parent-agent guidance block to inject into the outer system prompt. - The nested advisor prompt must say, in plain language: - you are advising the parent agent; - you do not address the end user directly; - you do not claim actions happened; - you return concise strategic guidance and tradeoffs. 3. **`runtime.go`** - Define the per-run runtime state. - Recommended fields: - resolved model + model config; - provider keys/options reused from the outer chat; - `MaxUsesPerRun`; - `MaxOutputTokens`; - atomic/current call counter; - callback(s) to obtain the current prompt snapshot and current-step snapshot; - optional metrics/usage hook. - Add fail-fast validation for impossible config: nil model, non-positive limits, empty prompt builders, etc. 4. **`handoff.go`** - Build the advisor handoff message from: - the explicit question; - the exact prompt/messages the parent model just used; - the current step's text/reasoning snapshot, if available; - the most recent relevant tool outputs, if they are already in the prompt snapshot. - **Important:** use the already-prepared outer prompt tail, not a fresh DB reload. That keeps the advisor aligned with compaction and the exact context the outer model saw. - Apply hard truncation budgets with recent-context bias. 5. **`runner.go`** - Execute the nested advisor call. - **Recommended implementation:** call `chatloop.Run()` in an in-memory, one-step mode: - `Tools: nil` - `ProviderTools: nil` - `MaxSteps: 1` - `PersistStep`: capture the assistant output in memory instead of writing DB rows - Reuse the existing provider/model/cost path instead of building a second provider runner. - Assert that no tool definitions are passed to the nested call. 6. **`chattool/advisor.go`** - Keep this file thin and consistent with other built-ins. - Responsibilities: - decode `AdvisorArgs`; - validate `Question` is non-empty and bounded; - call the `chatadvisor` runner; - return a structured tool response. ### Defensive programming requirements - Assert `Question` is non-empty after trimming. - Assert runtime limits are positive. - Assert the nested advisor call runs with zero tools/provider tools. - Assert `AdvisorResult.Type` is one of the known variants before returning. - Assert remaining uses never goes negative. ### Acceptance criteria - A unit test can call the advisor tool with a fake model and receive a stable `advice` result. - The nested advisor call is impossible to run with tools accidentally attached. - The core logic lives in `chatadvisor/`, not embedded inside `chatd.go`. ## Phase 2 — Wire advisor into chatd and keep prompt/tool availability in sync ### Goals Register the tool in the right place, expose it only when eligible, and inject system guidance only when the tool is present. ### Files to modify - `coderd/x/chatd/chatd.go` - optionally a small helper file if `chatd.go` becomes too crowded ### Tasks 1. **Compute one eligibility boolean in `processChat()`.** Recommended inputs: - server-level advisor enabled flag; - root chat only (`chat.ParentChatID == uuid.Nil` or equivalent existing root/child check); - a usable resolved model/provider exists; - optional experiment/workspace/org gate if product wants staged rollout. 2. **Create the runtime once per outer chat run.** - Use the model/config/keys resolved by `resolveChatModel()`. - Reuse provider options from the current chat's `ChatModelCallConfig`. - Set `MaxUsesPerRun` and `MaxOutputTokens` from advisor config defaults. 3. **Register the tool in the built-in tool block.** - Insert after the skill tools and before MCP tools in `processChat()`. - Record `builtinToolNames["advisor"] = true` so metrics stay bounded. 4. **Inject advisor guidance into the outer system prompt using the same boolean.** - Use `chatprompt.InsertSystem()` in the same prompt assembly path that already injects user/system instructions. - Place the block near the existing instruction insertion, before plan-path/skill context blocks. - Wrap the guidance in an explicit tag like `<advisor-guidance>` so it is easy to spot in tests and future refactors. 5. **Keep advisor out of child chats for the first release.** - That avoids recursion/cost blowups with `spawn_agent` / `wait_agent` flows. - Document this explicitly in the rollout notes and tests. ### Acceptance criteria - If advisor is disabled, neither the tool nor the prompt guidance appears. - If advisor is enabled, both the tool and the prompt guidance appear. - Root chats can use advisor; child chats cannot. - Built-in tool names include `advisor` so metrics do not collapse it into the generic `mcp` label. ## Phase 3 — Enforce planning-only execution policy in `chatloop` ### Goals Prevent the model from calling `advisor` and action tools in the same execution batch. ### Files to modify - `coderd/x/chatd/chatloop/chatloop.go` - related chatloop tests ### Recommended implementation Keep the MVP small; do **not** build a general policy engine yet. 1. Add a minimal field to `chatloop.RunOptions`, for example: ```go ExclusiveToolName *string ``` 2. In `Run()` / `executeTools()`, detect the case where the exclusive tool appears in the same local-tool batch as any other locally executed tool. 3. When that happens, synthesize structured tool-result errors for the affected calls instead of executing anything in the batch. - `advisor` should receive a clear error like: _advisor must be called by itself before action tools_. - The sibling action tools should receive a paired policy error like: _this tool was skipped because advisor must run alone_. 4. Let the outer model see those tool errors and retry cleanly. - This is simpler and safer than partial execution or hidden deferral. - It preserves deterministic transcript history for debugging. 5. Pass the just-finished step snapshot into the tool execution context. - The advisor runtime should be able to see the current step's text/reasoning content, because that is often the best hint about what the outer model is trying to decide. ### Why this is the right fit - It matches the intended semantics: advisor is consulted **before** taking action. - It avoids subtle race conditions caused by concurrent built-in tool execution. - It keeps the behavior easy to test with fake models. ### Acceptance criteria - A model-emitted batch containing only `advisor` succeeds. - A model-emitted batch containing `advisor` plus any other locally executed tool returns deterministic policy errors and executes nothing. - Non-advisor tool execution stays unchanged for normal chats. ## Phase 4 — Usage limits, metrics, and configuration ### Goals Make advisor safe to operate without over-designing billing/storage in the first release. ### Files to modify - `coderd/x/chatd/chatd.go` - `coderd/x/chatd/chatloop/metrics.go` as needed - `coderd/x/chatd/chatd.go` `Config` struct and constructor path - optional follow-up config/db files only if a separate advisor model or persistent billing is required ### Tasks 1. **Add explicit server config knobs for MVP.** Recommended fields on `chatd.Config` or a nested advisor config struct: - `AdvisorEnabled bool` - `AdvisorMaxUsesPerRun int` - `AdvisorMaxOutputTokens int64` 2. **Track usage per outer run.** - Reset the counter for each `processChat()` invocation. - Return `remaining_uses` in the tool result. - Return `limit_reached` when the cap is exhausted. 3. **Expose advisor usage metadata in the tool result.** - Include model name and token/cost summary if available. - Use the same `callConfig.Cost` calculation path as the outer chat for MVP if advisor reuses the same model. 4. **Record server-side metrics.** - Count advisor invocations, failures, and latency. - Ensure they show up under the built-in tool label `advisor`. 5. **Optional decision gate: separate advisor model.** - If product insists on a stronger/different advisor model, add a follow-up config hook that resolves another existing chat model config through the same `configCache` path. - Keep that out of the first landing PR unless it is required for acceptance. 6. **Optional decision gate: queryable advisor cost.** - If this becomes required, spin a follow-up DB task: - update `coderd/database/queries/*.sql`; - add migration files; - run `make gen`; - update audit mappings if a new auditable type/field is introduced. ### Acceptance criteria - Advisor calls are capped per outer run. - Limit exhaustion is user-visible in the tool result. - Metrics distinguish advisor calls from other built-in tools. - MVP does not require a schema migration unless explicitly approved. ## Phase 5 — Frontend rendering and Storybook coverage ### Goals Make advisor feel intentional in the Agents UI without blocking the backend on fancy streaming UI. ### Files to modify - `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx` - new `site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx` - Storybook story file(s) in the same tools directory ### Delivery strategy 1. **Intermediate milestone during backend bring-up:** rely on the existing generic tool renderer if needed. - This is acceptable only as a short-lived integration checkpoint. 2. **Release milestone:** add a dedicated lightweight `AdvisorTool` renderer. - Reuse existing primitives: - `ToolCollapsible` - `ToolIcon` - `Response` for markdown/prose rendering - `ScrollArea` if the advice can be long - Keep styling light and consistent with the Agents page. - Do not add unnecessary React memoization in `site/src/pages/AgentsPage/`; that area is already React-Compiler aware. 3. **Render the structured result states cleanly.** - `advice` — readable prose/markdown with optional metadata footer. - `limit_reached` — warning-style message. - `error` — error state with visible fallback text. - `running` — existing tool loading state/spinner is enough for MVP. 4. **Add Storybook coverage instead of ad-hoc component tests.** Recommended stories: - successful advice; - running/loading; - limit reached; - error. 5. **Keep the UI contract narrow.** - Prefer one text field like `advice` plus small metadata rather than a deeply nested schema. - That keeps the UI resilient to prompt iteration. ### Acceptance criteria - The advisor tool card renders readable content rather than raw quoted JSON in the final release branch. - Running, limit, and error states are visibly distinct. - Storybook stories and play assertions cover the new states. - Existing tool rendering flows remain unchanged. ## Phase 6 — Automated tests and validation gates ### Backend tests to add 1. **Advisor runtime/tool tests** - question validation; - tool-less nested execution assertion; - success result shaping; - limit-reached result shaping; - error result shaping. 2. **Prompt/gating tests in chatd** - advisor disabled ⇒ no tool, no guidance; - advisor enabled/root chat ⇒ tool + guidance; - child chat ⇒ advisor absent. 3. **Chatloop policy tests** - advisor alone runs; - advisor + action tool mixed batch returns deterministic policy errors; - non-advisor tools still execute normally. 4. **Usage/metrics tests** - per-run cap resets correctly; - builtin tool labeling includes `advisor`; - returned metadata includes model/usage summary when available. ### Frontend tests to add - Storybook `play()` assertions for the advisor renderer states. - Verify expand/collapse behavior and visible fallback text. - Verify the message timeline still renders adjacent tools correctly. ### Recommended command sequence Run these as the implementation matures, not only at the end: 1. Backend-focused gate after phases 1–4: - `make test RUN=TestAdvisor` - `make test RUN=TestChatloopAdvisor` - `make lint` 2. Frontend-focused gate after phase 5: - `pnpm test:storybook src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.stories.tsx` - `pnpm lint` - `pnpm format` 3. Final repo gate before handoff: - `make pre-commit` - run any additional targeted `make test RUN=...` selections covering touched chatd paths > Use the exact new test names the implementing agents create; the names above are recommended anchors, not existing tests. ## Dogfooding plan ### Principle Dogfood the change as a real agent feature, not just a unit-tested backend. Per the dogfood and `agent-browser` skills, the reviewer should get **watchable repro videos** plus screenshots that make the behavior obvious without reading logs. ### Required setup 1. Start the full dev environment with: - `./scripts/develop.sh` 2. If the frontend renderer changes, also start Storybook from `site/` with: - `pnpm storybook --no-open` 3. Use `agent-browser` directly — **never `npx agent-browser`**. 4. Use named browser sessions and an output folder such as: - `./dogfood-output/advisor/` - with subfolders `screenshots/` and `videos/` ### Evidence protocol For every interactive scenario below: 1. Start video recording **before** the action. 2. Capture step-by-step screenshots at human pace. 3. Capture one annotated screenshot of the final state. 4. Stop the recording. 5. Note the exact pass/fail observation in the QA report. For static UI states (for example Storybook error/limit cards), an annotated screenshot is sufficient; video is optional but still encouraged by this project’s review preference. ### Dogfood scenarios #### Scenario A — Happy path in the real Agents UI **Goal:** prove that a root agent chat can invoke advisor and produce a readable recommendation before taking further action. Steps: 1. Open the Agents page with an advisor-enabled root chat. 2. Start a repro video. 3. Send a prompt that should reasonably trigger strategic planning, such as an architecture or multi-tradeoff question. 4. Capture screenshots of: - the prompt before send; - the running advisor state; - the completed advisor card and the assistant’s follow-up response. 5. Stop recording. Pass criteria: - advisor appears in the timeline; - the rendered result is readable; - the assistant can continue after consuming the advisor output. #### Scenario B — Advisor unavailable path **Goal:** prove the feature is truly gated. Suggested variants (at least one is required, both are better): - feature flag/config off; - child/sub-agent chat. Evidence: - annotated screenshot of the chat/tool state showing advisor is absent; - short video if toggling the gate live is part of the repro. Pass criteria: - no advisor tool is available; - no advisor-specific prompt behavior leaks through. #### Scenario C — UI states in Storybook **Goal:** prove the renderer handles non-happy states cleanly. Required story states: - success/advice; - running; - limit reached; - error. Evidence: - one screenshot per state; - at least one short video showing collapse/expand behavior. Pass criteria: - success renders readable advice; - limit/error have visible fallback text; - the component behaves like the other tool cards. #### Scenario D — Regression sweep of nearby tools **Goal:** ensure advisor does not break the surrounding chat timeline. Check at minimum: - another existing built-in tool still renders correctly near advisor; - sub-agent/tool cards still expand/collapse normally; - no obvious console errors appear in the Agents page during the advisor flow. Evidence: - screenshots of adjacent tool cards; - console/error capture if anything suspicious appears. ### `agent-browser` usage notes for the QA agent - Prefer `agent-browser batch` for 2+ sequential commands when no intermediate parsing is needed. - Use `snapshot -i` to discover interactive refs. - Re-snapshot after navigation or major DOM changes. - Avoid `wait --load networkidle` unless the page is known to go idle; prefer explicit element/text waits or short fixed waits. - Record videos at human pace and include pauses that a reviewer can follow. ## Rollout plan ### Initial rollout - Gate behind a server-side advisor-enabled flag. - Enable only for selected internal/root agent chats first. - Watch metrics for: - invocation count; - failure rate; - latency; - obvious retry loops. ### Expansion conditions Expand beyond the initial rollout only after the following are true: - mixed-batch policy behavior is stable; - cost impact is understood; - frontend UX is readable in production-like dogfood; - no recursion surprises have appeared with sub-agent flows. ### Explicit non-goals for the first release - advisor inside child/sub-agent chats; - provider-agnostic streaming phase UI; - MCP-based external advisor implementation; - mandatory DB-backed advisor cost reporting. ## Final acceptance checklist - [ ] `advisor` is a built-in chatd tool, not an MCP/dynamic-tool substitute. - [ ] The nested advisor call is tool-less and bounded to one in-memory step. - [ ] One eligibility boolean controls both tool registration and prompt guidance injection. - [ ] Root chats can use advisor; child chats cannot in the initial rollout. - [ ] Mixed advisor/action batches produce deterministic policy errors instead of partial execution. - [ ] Per-run usage caps and limit-reached behavior work. - [ ] Advisor usage is visible in metadata/metrics without forcing a DB migration for MVP. - [ ] The Agents UI has a readable advisor card and Storybook coverage. - [ ] Dogfooding produced screenshots and repro videos for the required scenarios. - [ ] Validation commands (`make lint`, targeted `make test`, Storybook tests, `make pre-commit`) passed before handoff. ## Suggested PR split 1. **PR 1 — Backend foundation** - `chatadvisor/` package - `chattool/advisor.go` - `chatloop` exclusive policy - chatd gating/prompt sync - backend tests 2. **PR 2 — Frontend + QA** - advisor renderer - stories/play assertions - dogfood artifacts and QA notes 3. **PR 3 — Optional follow-ups only if demanded by stakeholders** - separate advisor model override - persistent advisor billing/queryability - transient phase-stream UX </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-7` • Thinking: `max`_
1604 lines
52 KiB
Go
1604 lines
52 KiB
Go
package chatprovider_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"charm.land/fantasy"
|
|
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
|
fantasybedrock "charm.land/fantasy/providers/bedrock"
|
|
fantasyopenai "charm.land/fantasy/providers/openai"
|
|
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
|
|
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
|
fantasyvercel "charm.land/fantasy/providers/vercel"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestResolveUserProviderKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configuredProvider := func(id uuid.UUID, provider string, centralEnabled bool, centralKey string, allowUser bool, allowCentralFallback bool) chatprovider.ConfiguredProvider {
|
|
return chatprovider.ConfiguredProvider{
|
|
ProviderID: id,
|
|
Provider: provider,
|
|
APIKey: centralKey,
|
|
CentralAPIKeyEnabled: centralEnabled,
|
|
AllowUserAPIKey: allowUser,
|
|
AllowCentralAPIKeyFallback: allowCentralFallback,
|
|
}
|
|
}
|
|
|
|
userProviderKey := func(id uuid.UUID, apiKey string) chatprovider.UserProviderKey {
|
|
return chatprovider.UserProviderKey{
|
|
ChatProviderID: id,
|
|
APIKey: apiKey,
|
|
}
|
|
}
|
|
|
|
openAIProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
anthropicProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
|
bedrockProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000003")
|
|
|
|
tests := []struct {
|
|
name string
|
|
fallback chatprovider.ProviderAPIKeys
|
|
providers []chatprovider.ConfiguredProvider
|
|
userKeys []chatprovider.UserProviderKey
|
|
wantAvailability map[string]chatprovider.ProviderAvailability
|
|
wantKeys map[string]string
|
|
wantKeyPresence map[string]bool
|
|
}{
|
|
{
|
|
name: "CentralOnlyKeyPresent",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
},
|
|
},
|
|
{
|
|
name: "CentralOnlyKeyMissing",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasyopenai.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralOnlyAmbientCredentialsEnabled",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockFallbackAmbientCredentialsEnabled",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockUserKeyRequiredWithoutFallback",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralDisabledMissingAPIKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, false, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralStoredKeyPresent",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "bedrock-token", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "bedrock-token",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "UserOnlyUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, false, "sk-central", true, false)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "UserOnlyUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, false, "sk-central", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOffUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, false)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOffUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, true)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnCentralKeyEmptyUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "MultipleProvidersDifferentPolicies",
|
|
providers: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", false, false),
|
|
configuredProvider(anthropicProviderID, fantasyanthropic.Name, false, "", true, false),
|
|
},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
fantasyanthropic.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
fantasyanthropic.Name: "",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys, availability := chatprovider.ResolveUserProviderKeys(tt.fallback, tt.providers, tt.userKeys)
|
|
|
|
require.Len(t, availability, len(tt.wantAvailability))
|
|
for provider, wantAvailability := range tt.wantAvailability {
|
|
gotAvailability, ok := availability[provider]
|
|
require.True(t, ok, "expected availability for provider %q", provider)
|
|
require.Equal(t, wantAvailability, gotAvailability)
|
|
require.Equal(t, tt.wantKeys[provider], keys.APIKey(provider))
|
|
}
|
|
for provider, wantPresent := range tt.wantKeyPresence {
|
|
gotKey, ok := keys.ByProvider[provider]
|
|
require.Equal(t, wantPresent, ok, "unexpected key presence for provider %q", provider)
|
|
require.Equal(t, wantPresent, keys.HasProvider(provider), "unexpected HasProvider result for provider %q", provider)
|
|
if wantPresent {
|
|
require.Equal(t, tt.wantKeys[provider], gotKey)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return fn(req)
|
|
}
|
|
|
|
func TestReasoningEffortFromChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
input *string
|
|
want *string
|
|
}{
|
|
{
|
|
name: "OpenAICaseInsensitive",
|
|
provider: "openai",
|
|
input: ptr.Ref(" HIGH "),
|
|
want: ptr.Ref(string(fantasyopenai.ReasoningEffortHigh)),
|
|
},
|
|
{
|
|
name: "OpenAIXHighEffort",
|
|
provider: "openai",
|
|
input: ptr.Ref("xhigh"),
|
|
want: ptr.Ref(string(fantasyopenai.ReasoningEffortXHigh)),
|
|
},
|
|
{
|
|
name: "AnthropicEffort",
|
|
provider: "anthropic",
|
|
input: ptr.Ref("max"),
|
|
want: ptr.Ref(string(fantasyanthropic.EffortMax)),
|
|
},
|
|
{
|
|
name: "AnthropicXHighEffort",
|
|
provider: "anthropic",
|
|
input: ptr.Ref("xhigh"),
|
|
want: ptr.Ref(string(fantasyanthropic.EffortXHigh)),
|
|
},
|
|
{
|
|
name: "OpenRouterEffort",
|
|
provider: "openrouter",
|
|
input: ptr.Ref("medium"),
|
|
want: ptr.Ref(string(fantasyopenrouter.ReasoningEffortMedium)),
|
|
},
|
|
{
|
|
name: "VercelEffort",
|
|
provider: "vercel",
|
|
input: ptr.Ref("xhigh"),
|
|
want: ptr.Ref(string(fantasyvercel.ReasoningEffortXHigh)),
|
|
},
|
|
{
|
|
name: "InvalidEffortReturnsNil",
|
|
provider: "openai",
|
|
input: ptr.Ref("unknown"),
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "UnsupportedProviderReturnsNil",
|
|
provider: "bedrock",
|
|
input: ptr.Ref("high"),
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "NilInputReturnsNil",
|
|
provider: "openai",
|
|
input: nil,
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := chatprovider.ReasoningEffortFromChat(tt.provider, tt.input)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider chatprovider.ConfiguredProvider
|
|
wantReason codersdk.ChatModelProviderUnavailableReason
|
|
}{
|
|
{
|
|
name: "FallbackConfiguredWithoutCentralKeyReturnsUserAPIKeyRequired",
|
|
provider: chatprovider.ConfiguredProvider{
|
|
Provider: "anthropic",
|
|
CentralAPIKeyEnabled: true,
|
|
AllowUserAPIKey: true,
|
|
AllowCentralAPIKeyFallback: true,
|
|
},
|
|
wantReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
{
|
|
name: "UserKeyRequiredWithoutFallback",
|
|
provider: chatprovider.ConfiguredProvider{
|
|
Provider: "anthropic",
|
|
CentralAPIKeyEnabled: true,
|
|
AllowUserAPIKey: true,
|
|
},
|
|
wantReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys, availability := chatprovider.ResolveUserProviderKeys(
|
|
chatprovider.ProviderAPIKeys{},
|
|
[]chatprovider.ConfiguredProvider{tt.provider},
|
|
nil,
|
|
)
|
|
|
|
require.Empty(t, keys.APIKey(tt.provider.Provider))
|
|
resolved, ok := availability[tt.provider.Provider]
|
|
require.True(t, ok)
|
|
require.False(t, resolved.Available)
|
|
require.Equal(t, tt.wantReason, resolved.UnavailableReason)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListConfiguredModels_PolicyAwareAvailability(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configuredProvider := func(provider string, apiKey string) chatprovider.ConfiguredProvider {
|
|
return chatprovider.ConfiguredProvider{
|
|
ProviderID: uuid.New(),
|
|
Provider: provider,
|
|
APIKey: apiKey,
|
|
}
|
|
}
|
|
enabledProviders := func(providers ...string) map[string]struct{} {
|
|
result := make(map[string]struct{}, len(providers))
|
|
for _, provider := range providers {
|
|
result[chatprovider.NormalizeProvider(provider)] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
catalog := chatprovider.NewModelCatalog()
|
|
tests := []struct {
|
|
name string
|
|
configuredProviders []chatprovider.ConfiguredProvider
|
|
configuredModels []chatprovider.ConfiguredModel
|
|
availabilityByProvider map[string]chatprovider.ProviderAvailability
|
|
enabledProviders map[string]struct{}
|
|
want codersdk.ChatModelsResponse
|
|
}{
|
|
{
|
|
name: "PolicyUnavailableOverridesConfiguredKey",
|
|
configuredProviders: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(fantasyopenai.Name, "sk-central"),
|
|
},
|
|
configuredModels: []chatprovider.ConfiguredModel{{
|
|
Provider: fantasyopenai.Name,
|
|
Model: "gpt-4",
|
|
}},
|
|
availabilityByProvider: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyopenai.Name,
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: fantasyopenai.Name + ":gpt-4",
|
|
Provider: fantasyopenai.Name,
|
|
Model: "gpt-4",
|
|
DisplayName: "gpt-4",
|
|
}},
|
|
}}},
|
|
},
|
|
{
|
|
name: "PolicyAvailableMarksProviderAvailable",
|
|
configuredProviders: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(fantasyanthropic.Name, "sk-central"),
|
|
},
|
|
configuredModels: []chatprovider.ConfiguredModel{{
|
|
Provider: fantasyanthropic.Name,
|
|
Model: "claude-3-5-sonnet",
|
|
}},
|
|
availabilityByProvider: map[string]chatprovider.ProviderAvailability{
|
|
fantasyanthropic.Name: {Available: true},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyanthropic.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyanthropic.Name,
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: fantasyanthropic.Name + ":claude-3-5-sonnet",
|
|
Provider: fantasyanthropic.Name,
|
|
Model: "claude-3-5-sonnet",
|
|
DisplayName: "claude-3-5-sonnet",
|
|
}},
|
|
}}},
|
|
},
|
|
{
|
|
name: "DisabledProviderOmitted",
|
|
configuredProviders: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(fantasyanthropic.Name, "sk-anthropic"),
|
|
configuredProvider(fantasyopenai.Name, "sk-openai"),
|
|
},
|
|
configuredModels: []chatprovider.ConfiguredModel{
|
|
{Provider: fantasyanthropic.Name, Model: "claude-3-5-sonnet"},
|
|
{Provider: fantasyopenai.Name, Model: "gpt-4"},
|
|
},
|
|
availabilityByProvider: map[string]chatprovider.ProviderAvailability{
|
|
fantasyanthropic.Name: {Available: true},
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyopenai.Name,
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: fantasyopenai.Name + ":gpt-4",
|
|
Provider: fantasyopenai.Name,
|
|
Model: "gpt-4",
|
|
DisplayName: "gpt-4",
|
|
}},
|
|
}}},
|
|
},
|
|
{
|
|
name: "MissingAvailabilityDefaultsToMissingAPIKey",
|
|
configuredProviders: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(fantasyopenai.Name, "sk-central"),
|
|
},
|
|
configuredModels: []chatprovider.ConfiguredModel{{
|
|
Provider: fantasyopenai.Name,
|
|
Model: "gpt-4o",
|
|
}},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyopenai.Name,
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey,
|
|
Models: []codersdk.ChatModel{{
|
|
ID: fantasyopenai.Name + ":gpt-4o",
|
|
Provider: fantasyopenai.Name,
|
|
Model: "gpt-4o",
|
|
DisplayName: "gpt-4o",
|
|
}},
|
|
}}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, ok := catalog.ListConfiguredModels(
|
|
tt.configuredProviders,
|
|
tt.configuredModels,
|
|
tt.availabilityByProvider,
|
|
tt.enabledProviders,
|
|
)
|
|
require.True(t, ok)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListConfiguredProviderAvailability_PolicyAwareFiltering(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabledProviders := func(providers ...string) map[string]struct{} {
|
|
result := make(map[string]struct{}, len(providers))
|
|
for _, provider := range providers {
|
|
result[chatprovider.NormalizeProvider(provider)] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
catalog := chatprovider.NewModelCatalog()
|
|
tests := []struct {
|
|
name string
|
|
availabilityByProvider map[string]chatprovider.ProviderAvailability
|
|
enabledProviders map[string]struct{}
|
|
want codersdk.ChatModelsResponse
|
|
}{
|
|
{
|
|
name: "EnabledProvidersUsePolicyAvailability",
|
|
availabilityByProvider: map[string]chatprovider.ProviderAvailability{
|
|
fantasyanthropic.Name: {
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyanthropic.Name, fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{
|
|
{
|
|
Provider: fantasyanthropic.Name,
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
Models: []codersdk.ChatModel{},
|
|
},
|
|
{
|
|
Provider: fantasyopenai.Name,
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "DisabledSupportedProviderOmitted",
|
|
availabilityByProvider: map[string]chatprovider.ProviderAvailability{
|
|
fantasyanthropic.Name: {Available: true},
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyopenai.Name,
|
|
Available: true,
|
|
Models: []codersdk.ChatModel{},
|
|
}}},
|
|
},
|
|
{
|
|
name: "MissingAvailabilityDefaultsToMissingAPIKey",
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
|
|
Provider: fantasyopenai.Name,
|
|
Available: false,
|
|
UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey,
|
|
Models: []codersdk.ChatModel{},
|
|
}}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := catalog.ListConfiguredProviderAvailability(
|
|
tt.availabilityByProvider,
|
|
tt.enabledProviders,
|
|
)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPruneDisabledProviderKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabledProviders := func(providers ...string) map[string]struct{} {
|
|
result := make(map[string]struct{}, len(providers))
|
|
for _, provider := range providers {
|
|
result[chatprovider.NormalizeProvider(provider)] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keys chatprovider.ProviderAPIKeys
|
|
enabledProviders map[string]struct{}
|
|
want chatprovider.ProviderAPIKeys
|
|
}{
|
|
{
|
|
name: "DisabledProviderEntriesRemoved",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OpenAIDisabledClearsLegacyField",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyanthropic.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "AnthropicDisabledClearsLegacyField",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "AllEnabledLeavesKeysUnchanged",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name, fantasyanthropic.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := tt.keys
|
|
chatprovider.PruneDisabledProviderKeys(&keys, tt.enabledProviders)
|
|
require.Equal(t, tt.want, keys)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoderHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RootChatNoWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, chatID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderSubchatID)
|
|
require.NotContains(t, h, chatprovider.HeaderCoderWorkspaceID)
|
|
})
|
|
|
|
t.Run("RootChatWithWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, chatID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderSubchatID)
|
|
require.Equal(t, workspaceID.String(), h[chatprovider.HeaderCoderWorkspaceID])
|
|
})
|
|
|
|
t.Run("SubchatWithWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, parentID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.Equal(t, subchatID.String(), h[chatprovider.HeaderCoderSubchatID])
|
|
require.Equal(t, workspaceID.String(), h[chatprovider.HeaderCoderWorkspaceID])
|
|
})
|
|
|
|
t.Run("SubchatNoWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, parentID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.Equal(t, subchatID.String(), h[chatprovider.HeaderCoderSubchatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderWorkspaceID)
|
|
})
|
|
}
|
|
|
|
func TestModelFromConfig_Bedrock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const modelID = "us.anthropic.claude-sonnet-4-20250514-v1:0"
|
|
|
|
// This verifies the policy gate that permits an empty Bedrock key.
|
|
// End-to-end ambient credential auth would need a real AWS
|
|
// environment or a more complete mock, which is outside this scope.
|
|
t.Run("AllowsEmptyAPIKeyForAmbientCredentials", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, model)
|
|
require.Equal(t, fantasybedrock.Name, model.Provider())
|
|
})
|
|
|
|
t.Run("RequiresResolvedProviderForAmbientCredentials", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.Nil(t, model)
|
|
require.EqualError(t, err, "API key for provider \"bedrock\" is not set")
|
|
})
|
|
|
|
t.Run("ForwardsBaseURLAndExplicitAPIKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
type requestCapture struct {
|
|
Path string
|
|
Authorization string
|
|
UserAgent string
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests <- requestCapture{
|
|
Path: r.URL.Path,
|
|
Authorization: r.Header.Get("Authorization"),
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "test-key",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, model)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.Equal(t, "/model/"+modelID+"/invoke", got.Path)
|
|
require.Equal(t, "Bearer test-key", got.Authorization)
|
|
require.Equal(t, chatprovider.UserAgent(), got.UserAgent)
|
|
})
|
|
|
|
t.Run("NonBedrockStillRequiresAPIKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
model string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "OpenAI",
|
|
provider: fantasyopenai.Name,
|
|
model: "gpt-4",
|
|
wantErr: "OPENAI_API_KEY is not set",
|
|
},
|
|
{
|
|
name: "Anthropic",
|
|
provider: fantasyanthropic.Name,
|
|
model: "claude-sonnet-4-20250514",
|
|
wantErr: "ANTHROPIC_API_KEY is not set",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
tt.provider,
|
|
tt.model,
|
|
chatprovider.ProviderAPIKeys{},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.Nil(t, model)
|
|
require.EqualError(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestModelFromConfig_BedrockStripsAnthropicHeaders is a regression test
|
|
// for a bug where the Anthropic SDK reads ANTHROPIC_API_KEY from the
|
|
// process environment and adds X-Api-Key and Anthropic-Version headers to
|
|
// every request. On Bedrock, these headers conflict with SigV4 signing and
|
|
// cause auth failures. The SDK's Bedrock middleware strips them before
|
|
// signing. This test verifies the outgoing request shape with both
|
|
// Anthropic and AWS credentials present.
|
|
func TestModelFromConfig_BedrockStripsAnthropicHeaders(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
t.Setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
|
t.Setenv("AWS_REGION", "us-east-2")
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
|
|
|
|
type requestCapture struct {
|
|
Authorization string
|
|
AnthropicVersion string
|
|
XAPIKey string
|
|
Body string
|
|
ReadError error
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
requests <- requestCapture{
|
|
Authorization: r.Header.Get("Authorization"),
|
|
AnthropicVersion: r.Header.Get("Anthropic-Version"),
|
|
XAPIKey: r.Header.Get("X-Api-Key"),
|
|
Body: string(body),
|
|
ReadError: err,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
"anthropic.claude-opus-4-6-v1",
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, model)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.NoError(t, got.ReadError)
|
|
require.Empty(t, got.AnthropicVersion)
|
|
require.Empty(t, got.XAPIKey)
|
|
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
|
|
require.NotContains(t, got.Authorization, "anthropic-version")
|
|
require.NotContains(t, got.Authorization, "x-api-key")
|
|
require.Contains(t, got.Body, `"anthropic_version":"bedrock-2023-05-31"`)
|
|
}
|
|
|
|
func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
t.Setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
|
t.Setenv("AWS_REGION", "us-east-2")
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
|
|
|
|
type requestCapture struct {
|
|
Path string
|
|
Accept string
|
|
BedrockAccept string
|
|
Authorization string
|
|
Body string
|
|
ReadError error
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
requests <- requestCapture{
|
|
Path: r.URL.Path,
|
|
Accept: r.Header.Get("Accept"),
|
|
BedrockAccept: r.Header.Get("X-Amzn-Bedrock-Accept"),
|
|
Authorization: r.Header.Get("Authorization"),
|
|
Body: string(body),
|
|
ReadError: err,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/vnd.amazon.eventstream")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
"anthropic.claude-opus-4-6-v1",
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, model)
|
|
|
|
stream, err := model.Stream(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for part := range stream {
|
|
require.NotEqual(t, fantasy.StreamPartTypeError, part.Type)
|
|
break
|
|
}
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.NoError(t, got.ReadError)
|
|
require.Equal(t, "/model/us.anthropic.claude-opus-4-6-v1/invoke-with-response-stream", got.Path)
|
|
require.Empty(t, got.Accept)
|
|
require.Equal(t, "application/json", got.BedrockAccept)
|
|
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
|
|
require.Contains(t, got.Authorization, "x-amzn-bedrock-accept")
|
|
require.Contains(t, got.Body, `"anthropic_version":"bedrock-2023-05-31"`)
|
|
}
|
|
|
|
func bedrockNonStreamingResponse() map[string]any {
|
|
return map[string]any{
|
|
"id": "msg_01Test",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"content": []any{
|
|
map[string]any{
|
|
"type": "text",
|
|
"text": "Hi there",
|
|
},
|
|
},
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": "",
|
|
"usage": map[string]any{
|
|
"cache_creation": map[string]any{
|
|
"ephemeral_1h_input_tokens": 0,
|
|
"ephemeral_5m_input_tokens": 0,
|
|
},
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"input_tokens": 5,
|
|
"output_tokens": 2,
|
|
"server_tool_use": map[string]any{
|
|
"web_search_requests": 0,
|
|
},
|
|
"service_tier": "standard",
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestModelFromConfig_ExtraHeaders verifies that extra headers passed
|
|
// to ModelFromConfig are sent on outgoing LLM API requests. Only the
|
|
// OpenAI and Anthropic providers are tested end-to-end because the
|
|
// WithHeaders injection is the same mechanical pattern across all
|
|
// eight provider cases, and these are the only two providers with
|
|
// chattest test servers. CoderHeaders construction is tested
|
|
// separately in TestCoderHeaders.
|
|
func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
headers := chatprovider.CoderHeaders(chat)
|
|
|
|
assertCoderHeaders := func(t *testing.T, got http.Header) {
|
|
t.Helper()
|
|
assert.Equal(t, ownerID.String(), got.Get(chatprovider.HeaderCoderOwnerID))
|
|
assert.Equal(t, parentID.String(), got.Get(chatprovider.HeaderCoderChatID))
|
|
assert.Equal(t, subchatID.String(), got.Get(chatprovider.HeaderCoderSubchatID))
|
|
assert.Equal(t, workspaceID.String(), got.Get(chatprovider.HeaderCoderWorkspaceID))
|
|
}
|
|
|
|
t.Run("OpenAI", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
assertCoderHeaders(t, req.Header)
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
})
|
|
|
|
t.Run("Anthropic", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
|
assertCoderHeaders(t, req.Header)
|
|
close(called)
|
|
return chattest.AnthropicNonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"anthropic": "test-key"},
|
|
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
})
|
|
}
|
|
|
|
func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
// Coder headers must be absent when nil is passed.
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderOwnerID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderChatID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderSubchatID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderWorkspaceID))
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
func TestModelFromConfig_HTTPClient(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
assert.Equal(t, "true", req.Header.Get("X-Test-Transport"))
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
client := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
cloned := req.Clone(req.Context())
|
|
cloned.Header = req.Header.Clone()
|
|
cloned.Header.Set("X-Test-Transport", "true")
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})}
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
"openai",
|
|
"gpt-4",
|
|
keys,
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
client,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
options := &codersdk.ChatModelProviderOptions{
|
|
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
|
Reasoning: &codersdk.ChatModelReasoningOptions{
|
|
Enabled: ptr.Ref(true),
|
|
},
|
|
Provider: &codersdk.ChatModelOpenRouterProvider{
|
|
Order: []string{"openai"},
|
|
},
|
|
},
|
|
}
|
|
defaults := &codersdk.ChatModelProviderOptions{
|
|
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
|
Reasoning: &codersdk.ChatModelReasoningOptions{
|
|
Enabled: ptr.Ref(false),
|
|
Exclude: ptr.Ref(true),
|
|
MaxTokens: ptr.Ref[int64](123),
|
|
Effort: ptr.Ref("high"),
|
|
},
|
|
IncludeUsage: ptr.Ref(true),
|
|
Provider: &codersdk.ChatModelOpenRouterProvider{
|
|
Order: []string{"anthropic"},
|
|
AllowFallbacks: ptr.Ref(true),
|
|
RequireParameters: ptr.Ref(false),
|
|
DataCollection: ptr.Ref("allow"),
|
|
Only: []string{"openai"},
|
|
Ignore: []string{"foo"},
|
|
Quantizations: []string{"int8"},
|
|
Sort: ptr.Ref("latency"),
|
|
},
|
|
},
|
|
}
|
|
|
|
chatprovider.MergeMissingProviderOptions(&options, defaults)
|
|
|
|
require.NotNil(t, options)
|
|
require.NotNil(t, options.OpenRouter)
|
|
require.NotNil(t, options.OpenRouter.Reasoning)
|
|
require.True(t, *options.OpenRouter.Reasoning.Enabled)
|
|
require.Equal(t, true, *options.OpenRouter.Reasoning.Exclude)
|
|
require.EqualValues(t, 123, *options.OpenRouter.Reasoning.MaxTokens)
|
|
require.Equal(t, "high", *options.OpenRouter.Reasoning.Effort)
|
|
require.NotNil(t, options.OpenRouter.IncludeUsage)
|
|
require.True(t, *options.OpenRouter.IncludeUsage)
|
|
|
|
require.NotNil(t, options.OpenRouter.Provider)
|
|
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Order)
|
|
require.NotNil(t, options.OpenRouter.Provider.AllowFallbacks)
|
|
require.True(t, *options.OpenRouter.Provider.AllowFallbacks)
|
|
require.NotNil(t, options.OpenRouter.Provider.RequireParameters)
|
|
require.False(t, *options.OpenRouter.Provider.RequireParameters)
|
|
require.Equal(t, "allow", *options.OpenRouter.Provider.DataCollection)
|
|
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Only)
|
|
require.Equal(t, []string{"foo"}, options.OpenRouter.Provider.Ignore)
|
|
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
|
|
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
|
|
}
|
|
|
|
// TestApplyReasoningEffortToOptions covers every provider's mutation branch
|
|
// plus the seeding path for missing provider entries. A typo or wrong type
|
|
// assertion in any branch fails a unit test here rather than silently
|
|
// dropping the admin-configured reasoning effort in chatd callers.
|
|
func TestApplyReasoningEffortToOptions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("NilOptionsAndNilModelIsNoOp", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Must not panic when options and model are both nil.
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, nil, "medium")
|
|
require.Nil(t, got)
|
|
})
|
|
|
|
t.Run("EmptyEffortReturnsInputUnchanged", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, " ")
|
|
require.Nil(t, got)
|
|
})
|
|
|
|
t.Run("EmptyEffortPreservesExistingOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
effort := fantasyopenai.ReasoningEffortLow
|
|
opts := &fantasyopenai.ProviderOptions{ReasoningEffort: &effort}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
|
|
|
|
got := chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "")
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortLow, *opts.ReasoningEffort)
|
|
// The input map must be returned untouched rather than allocated anew.
|
|
require.Len(t, got, 1)
|
|
})
|
|
|
|
t.Run("UnrecognizedEffortLeavesOptionsUntouched", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyopenai.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "not-a-real-effort")
|
|
require.Nil(t, opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("OpenAIProviderOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyopenai.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("OpenAIResponsesProviderOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyopenai.ResponsesProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("OpenAICompatProviderOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyopenaicompat.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenaicompat.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("AnthropicProviderOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyanthropic.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyanthropic.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
|
|
require.NotNil(t, opts.Effort)
|
|
require.Equal(t, fantasyanthropic.EffortHigh, *opts.Effort)
|
|
})
|
|
|
|
t.Run("OpenRouterAllocatesReasoningOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyopenrouter.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenrouter.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "medium")
|
|
require.NotNil(t, opts.Reasoning, "Reasoning container must be allocated")
|
|
require.NotNil(t, opts.Reasoning.Effort)
|
|
require.Equal(t, fantasyopenrouter.ReasoningEffort("medium"), *opts.Reasoning.Effort)
|
|
})
|
|
|
|
t.Run("OpenRouterPreservesExistingReasoningContainer", func(t *testing.T) {
|
|
t.Parallel()
|
|
enabled := true
|
|
opts := &fantasyopenrouter.ProviderOptions{
|
|
Reasoning: &fantasyopenrouter.ReasoningOptions{Enabled: &enabled},
|
|
}
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenrouter.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
|
|
require.NotNil(t, opts.Reasoning.Enabled)
|
|
require.True(t, *opts.Reasoning.Enabled)
|
|
require.NotNil(t, opts.Reasoning.Effort)
|
|
require.Equal(t, fantasyopenrouter.ReasoningEffort("high"), *opts.Reasoning.Effort)
|
|
})
|
|
|
|
t.Run("VercelAllocatesReasoningOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
opts := &fantasyvercel.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{fantasyvercel.Name: opts}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "minimal")
|
|
require.NotNil(t, opts.Reasoning)
|
|
require.NotNil(t, opts.Reasoning.Effort)
|
|
require.Equal(t, fantasyvercel.ReasoningEffortMinimal, *opts.Reasoning.Effort)
|
|
})
|
|
|
|
t.Run("MultipleProvidersReceiveMutations", func(t *testing.T) {
|
|
t.Parallel()
|
|
openaiOpts := &fantasyopenai.ProviderOptions{}
|
|
anthropicOpts := &fantasyanthropic.ProviderOptions{}
|
|
providerOptions := fantasy.ProviderOptions{
|
|
fantasyopenai.Name: openaiOpts,
|
|
fantasyanthropic.Name: anthropicOpts,
|
|
}
|
|
|
|
chatprovider.ApplyReasoningEffortToOptions(providerOptions, nil, "high")
|
|
require.NotNil(t, openaiOpts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortHigh, *openaiOpts.ReasoningEffort)
|
|
require.NotNil(t, anthropicOpts.Effort)
|
|
require.Equal(t, fantasyanthropic.EffortHigh, *anthropicOpts.Effort)
|
|
})
|
|
|
|
t.Run("SeedsOpenAICompletionsWhenModelHasNoOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A model name absent from the Responses allowlist must seed
|
|
// the completions options struct so reasoning_effort lands.
|
|
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "not-a-real-openai-model"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
|
|
require.NotNil(t, got)
|
|
opts, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions)
|
|
require.True(t, ok, "expected *ProviderOptions for non-Responses model, got %T", got[fantasyopenai.Name])
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("SeedsOpenAIResponsesWhenModelIsResponsesModel", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A model name in the Responses allowlist must seed the
|
|
// Responses-specific options struct so the provider routes to
|
|
// the Responses endpoint.
|
|
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
|
|
require.NotNil(t, got)
|
|
opts, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
|
|
require.True(t, ok, "expected *ResponsesProviderOptions for Responses model, got %T", got[fantasyopenai.Name])
|
|
require.NotNil(t, opts.ReasoningEffort)
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *opts.ReasoningEffort)
|
|
})
|
|
|
|
t.Run("SeedsAnthropicWhenModelHasNoOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := &chattest.FakeModel{ProviderName: fantasyanthropic.Name, ModelName: "claude-3-5"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "high")
|
|
require.NotNil(t, got)
|
|
opts, ok := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, opts.Effort)
|
|
require.Equal(t, fantasyanthropic.EffortHigh, *opts.Effort)
|
|
})
|
|
|
|
t.Run("SeedsOpenRouterWhenModelHasNoOptions", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := &chattest.FakeModel{ProviderName: fantasyopenrouter.Name, ModelName: "openrouter-x"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "low")
|
|
require.NotNil(t, got)
|
|
opts, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, opts.Reasoning)
|
|
require.NotNil(t, opts.Reasoning.Effort)
|
|
require.Equal(t, fantasyopenrouter.ReasoningEffort("low"), *opts.Reasoning.Effort)
|
|
})
|
|
|
|
t.Run("UnknownProviderReturnsInputUnchanged", func(t *testing.T) {
|
|
t.Parallel()
|
|
model := &chattest.FakeModel{ProviderName: "unknown", ModelName: "x"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(nil, model, "medium")
|
|
require.Nil(t, got)
|
|
})
|
|
|
|
t.Run("PreservesExistingProviderEntry", func(t *testing.T) {
|
|
t.Parallel()
|
|
existing := &fantasyopenai.ProviderOptions{}
|
|
existingEffort := fantasyopenai.ReasoningEffortLow
|
|
existing.ReasoningEffort = &existingEffort
|
|
providerOptions := fantasy.ProviderOptions{fantasyopenai.Name: existing}
|
|
|
|
model := &chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}
|
|
got := chatprovider.ApplyReasoningEffortToOptions(providerOptions, model, "medium")
|
|
require.Same(t, existing, got[fantasyopenai.Name],
|
|
"existing provider entry must not be replaced")
|
|
// The reasoning effort on the existing entry is overwritten.
|
|
require.Equal(t, fantasyopenai.ReasoningEffortMedium, *existing.ReasoningEffort)
|
|
})
|
|
}
|