Skip to content

feat(llm): add Cohere chat adapter (command-r / command-r-plus)#13

Open
mvanhorn wants to merge 1 commit into
jstuart0:mainfrom
mvanhorn:feat/cohere-adapter
Open

feat(llm): add Cohere chat adapter (command-r / command-r-plus)#13
mvanhorn wants to merge 1 commit into
jstuart0:mainfrom
mvanhorn:feat/cohere-adapter

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

What

Adds a Cohere Chat API adapter so users can route the watcher to command-r / command-r-plus instead of being limited to Anthropic + the OpenAI-compatible surface.

Cohere's wire format differs from OpenAI's:

  • Request: { message, preamble, chat_history? }
  • Response: { text, meta: { tokens: { input_tokens, output_tokens } } }
  • Error: { message: \"...\" } (flat, no nested error object)

The adapter maps systemPrompt -> preamble and transcriptPrompt -> message, matching how the watcher uses every other adapter (single system + single user turn per call). chat_history is omitted because the watcher always sends a fresh transcript excerpt; multi-turn history is the caller's responsibility.

Files

  • cohere.ts (new): adapter using /v1/chat, Bearer auth, classifyHttpError, and a Cohere-specific error message extractor
  • types.ts: \"cohere\" added to ProviderKind
  • registry.ts: getAdapter routes kind === \"cohere\" to createCohereAdapter; defaultBaseUrl returns https://api.cohere.com
  • pricing.ts: adds command-r (15/60c per 1M) and command-r-plus (250/1000c per 1M)
  • list-models.ts: adds a Cohere branch hitting /v1/models with Bearer auth (the existing .models fallback in the parser already handles Cohere's response shape)
  • llm.test.ts: 5 new adapter tests + 2 pricing tests

Test plan

  • bun test src/server/services/ai/llm/llm.test.ts: 20 pass / 0 fail (52 expect() calls, was 13 / 0 before)
  • bun run typecheck: 0 errors (the new ProviderKind exhausted a previously-incomplete switch in list-models.ts; that's now fixed)
  • bunx biome check src/server/services/ai/llm/: 0 errors

Notes

  • completeStream is intentionally not implemented in this PR. Cohere streams via SSE-style event: text-generation chunks; the existing streamWithFallback wrapper in types.ts will serve full responses as a single delta until that's added in a follow-up.
  • extractCohereErrorMessage parses the { message } shape and falls back to the raw body on parse failure, so opaque error responses still produce useful LlmError.message text.

Closes #7

Adds command-r and command-r-plus support via Cohere's Chat API,
matching the issue jstuart0#7 brief. Cohere uses a different wire format
than OpenAI's chat completions:

  Request:  { message, preamble, chat_history? }
  Response: { text, meta: { tokens: {...} } }
  Error:    { message: '...' }   (no nested error object)

The adapter maps systemPrompt -> preamble and transcriptPrompt -> message,
which matches how the watcher uses the existing adapters (single
system + single user turn per call). chat_history is left empty
because the watcher always sends a fresh transcript excerpt.

Pricing rows added for command-r (15c/60c per 1M input/output) and
command-r-plus (250c/1000c per 1M), placing them between Gemini Flash
and OpenAI gpt-4o respectively.

list-models gains a Cohere branch that hits /v1/models with a Bearer
Authorization header. The existing { models: [...] } fallback in the
parser already handles Cohere's response shape, so the listing branch
is small.

Test plan:
  - bun test src/server/services/ai/llm/llm.test.ts  -- 20 pass
    (5 new cohere adapter cases + 2 new pricing cases)
  - bun run typecheck                                -- 0 errors
  - bunx biome check src/server/services/ai/llm/    -- 0 errors

Closes jstuart0#7
jstuart0 added a commit that referenced this pull request Apr 28, 2026
A code-health audit found 22 instances of string-literal-union types
duplicated server↔client (or paired with hand-maintained runtime
allowlists) with no compile-time enforcement. The recent Cohere PR
(#13) exposed this: it added a ProviderKind value to the type but
missed three other places where the kind list lived independently.

This slice closes 10 of the highest-leverage simple cases using one
uniform recipe:

  export const KIND_NAMES = ["a", "b", "c"] as const;
  export type KindName = (typeof KIND_NAMES)[number];

so adding a kind requires updating exactly one declaration and tsc
chases every consumer.

Fixes:
1. ProviderKind — canonical KNOWN_PROVIDER_KINDS in shared/types.ts.
   5 sites consolidated; AiSettingsPanel UI dropdown gains an
   exhaustiveness assertion via `as const satisfies`. Pricing
   FREE_KINDS, route allowlist, and client AiProviderKind alias all
   re-export.
2. AgentType — derived from AGENT_TYPES tuple in shared/constants.
   AGENT_TYPE_LABELS retyped to Record<AgentType, string>.
   routes/projects.ts and template-preview.ts drop hand-maintained
   allowlists in favor of AGENT_TYPES.includes().
3. SessionStatus — derived from SESSION_STATUSES;
   STATUS_COLORS: Record<SessionStatus, string>.
4. SemanticStatus — derived from SEMANTIC_STATUSES;
   SEMANTIC_STATUS_COLORS: Record<SemanticStatus, string>.
5. ApprovalPolicy + SandboxMode — APPROVAL_POLICIES / SANDBOX_MODES
   `as const` tuples; templates/utils.ts dropdowns spread the tuples.
6. AskMessageRole — single shared type; ask-service + api.ts both
   import.
7. WatcherPolicy — single shared type; watcher-config-service + api
   re-export.
8. DecisionKind — single shared type; 2-member subsets at
   InboxWorkItem.decision and SendChannelMessageInput.decision now
   Extract<DecisionKind, "continue" | "ask"> so removing either from
   the parent union breaks compile.
9. HitlReplyKind — single shared HITL_REPLY_KINDS; 6 inline literals
   replaced.
10. ActionRequestKind — converted from asserted-array to derived
    type. Cross-validation with ActionRequestPayload["kind"] union;
    removing an entry cascades errors through inbox-service and
    ask-bulk-action-handler.

Real bug exposed: AgentTypeBadge.tsx was indexing AGENT_TYPE_LABELS
with an arbitrary string prop. The tightened Record<AgentType, …>
rejected the access; an explicit fallback comment + cast documents
that legacy data may carry unknown values.

Tests:
- New shared/types.test.ts (7 tests): asserts every member of each
  KNOWN_*_KINDS const is type-compatible and the runtime check
  rejects bogus values.
- Existing tests untouched (594/594 pass).

Sanity-checked the exhaustiveness guards by temporarily removing
entries — confirmed tsc fires for ProviderKind, ActionRequestKind,
and AgentType.

587 → 594 tests pass; typecheck clean; biome 0 errors.
jstuart0 added a commit that referenced this pull request Apr 29, 2026
Audit findings #14, #17, #18, #19, #13, #22 — final cleanup of the
type-duplication audit. Every finding from the original audit is now
addressed across TYPE-2a/b/c/d.

Fix 1 — EventCategory exhaustiveness (#17, High):
- TimelineView.tsx eventLabel: replaced default: "Event" with explicit
  cases for all 7 ai_* categories + a never exhaustive guard. The
  ai_* events previously rendered with an anonymous "Event" label;
  now identify themselves: "AI Proposal", "AI HITL", "AI Continue",
  "AI Report", etc.
- ai/context.ts renderEventLine: same treatment. The classifier
  context was dropping AI events entirely (default: ""); now each
  contributes a one-liner like "[ai] proposal: continue".

Fix 2 — AskThreadOrigin (#14):
- Hoisted ASK_THREAD_ORIGINS as const + AskThreadOrigin to
  src/shared/types.ts. Replaced the inline "web" | "telegram"
  literal at 44 sites across 14 files.

Fix 3 — ActionRequestDecision (#19):
- Hoisted ACTION_REQUEST_DECISIONS + type to shared. 12 inbox cards
  + InboxPage + api.ts + routes/ai.ts + 2 service compare sites
  collapsed.

Fix 4 — WatcherRunTriggerKind (#13):
- Server-only type; context.ts now imports from
  watcher-runs-service.ts, dropping the 5-member inline duplicate.

Fix 5 — InboxKind Extract (#18):
- inbox-snooze-service.ts InboxKind now Extract<InboxWorkItem["kind"],
  "hitl" | "stuck" | "risky" | "failed_proposal">. Renaming any of
  those four kinds in InboxWorkItem now cascades to the snooze
  service immediately.

Fix 6 — LabsFlag (#22):
- Hoisted KNOWN_LABS_FLAGS + LabsFlag + LabsFlags to shared. Both
  labs-service.ts and web/lib/api.ts re-export. Compile-time
  assertion that LABS_REGISTRY covers every flag.

Behavior change worth flagging: AI events in the session timeline
were previously labeled "Event" and dropped from classifier context;
now have explicit labels and one-line context entries. Visible
improvement (AI events were anonymous), not a regression.

Sanity checks (both passed):
- Removing case "ai_proposal" → tsc fires '"ai_proposal"' is not
  assignable to type 'never'. Restored.
- Renaming kind: "hitl" in InboxWorkItem → tsc cascades to
  snooze-service Extract + 5 other sites. Restored.

Tests:
- shared/types.test.ts (+3 allowlist membership tests)

609 → 612 tests pass; typecheck clean; biome 0 errors.

This closes the type-duplication audit. All 22 findings collapsed
across TYPE-2a/b/c/d.

Refs evaluation-report.md type-duplication audit #13, #14, #17, #18,
#19, #22.
@jstuart0
Copy link
Copy Markdown
Owner

Thanks for this — really appreciate the work, and apologies for the timing churn. After you opened this, we landed a refactor on main (commit 781f2b0, slice TYPE-2a) that consolidates ProviderKind into a single KNOWN_PROVIDER_KINDS as const declaration in src/shared/types.ts. The runtime allowlist in routes/ai.ts, the web-side AiProviderKind type, and the UI dropdown's exhaustiveness guard all derive from it now — so adding a kind only requires updating one place rather than four.

Net effect: your diff gets smaller after a rebase.

Concrete checklist for main:

  1. src/shared/types.ts — add "cohere" to KNOWN_PROVIDER_KINDS
  2. src/server/services/ai/llm/cohere.ts — your adapter (unchanged)
  3. src/server/services/ai/llm/registry.ts — your getAdapter case + defaultBaseUrl entry (the existing exhaustive switch will require this to compile)
  4. src/server/services/ai/llm/list-models.ts — your Cohere /v1/models branch (existing exhaustive switch likewise)
  5. src/server/services/ai/llm/pricing.ts — your two MODEL_RATES rows (kept the command-r-plus before command-r ordering — nice catch on first-match)
  6. src/web/components/settings/AiSettingsPanel.tsx — add an entry to PROVIDER_KIND_OPTIONS. The as const satisfies exhaustiveness check will fail compile if you forget this step.
  7. src/server/services/ai/llm/llm.test.ts — your tests (unchanged)

You can drop the changes to src/server/routes/ai.ts:494-500 (the local PROVIDER_KINDS array no longer exists — the runtime check is now KNOWN_PROVIDER_KINDS.includes(...)) and to src/web/lib/api.ts:1123 (AiProviderKind is now a re-export of the shared type).

Once rebased, all your existing 7 tests stay green, plus tsc will flag any wiring point you miss — a strict improvement over the state this PR was originally written against.

Happy to review the rebase when you push it, or take it from here ourselves if you'd rather not deal with the churn — just say the word. Either way, thanks again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Cohere LLM adapter

2 participants