mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
feat(agents): v0.38 Slice 1 foundation — migration v81 + capabilities module
Adds the storage substrate for the gateway-native subagent tool loop:
- migration v81 adds subagent_tool_executions.ordinal + .gbrain_tool_use_id
+ UNIQUE(job_id, message_idx, ordinal). NULL-tolerant so legacy rows
survive untouched; the v0.38 read-time D5 shim recomputes the stable
key for pre-v81 rows from (job_id, message_idx, content_blocks index,
tool_name) without a data migration. Engine-aware via sqlFor.pglite.
- src/core/ai/capabilities.ts reads ChatTouchpoint fields from each
recipe and exposes getProviderCapabilities() + classifyCapabilities()
with a 5-state verdict (ok / degraded:no_caching / degraded:no_parallel
/ unusable:no_tools / unknown). This is what enforceSubagentCapable
(D7, S1.8) will gate on once the queue.ts pin removal (S1.7) lands.
- 12 unit cases in test/ai/capabilities.test.ts pin the verdict matrix
across Anthropic, OpenAI, Google, voyage (no chat → unknown), unknown
provider, missing-colon malformed input.
Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Wave: v0.38 (Agents+Minions cathedral; CEO + Eng + 2x Codex cleared).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1580c6d1ca
commit
14d3f987e9
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Provider capability detection for the gateway-native subagent tool loop.
|
||||
*
|
||||
* Pre-v0.38 the subagent loop was Anthropic-direct (`new Anthropic()` instantiated
|
||||
* in `src/core/minions/handlers/subagent.ts`). The three-layer pin
|
||||
* (`queue.ts:87-106` + `subagent.ts:149-167` + `doctor.ts:1190-1225` enforced
|
||||
* Anthropic-only because crash-replay relied on Anthropic's stable `tool_use_id`s
|
||||
* for reconciliation. v0.38 (D11) moves the stable-ID generation gbrain-side
|
||||
* (ordinal + uuid v7 persisted in `subagent_tool_executions` at first observation),
|
||||
* which decouples the loop from any specific provider's response format.
|
||||
*
|
||||
* This module reads capabilities from the recipe (`src/core/ai/recipes/*.ts`)
|
||||
* and surfaces them via a normalized `ProviderCapabilities` shape that the
|
||||
* gateway's `toolLoop()` consumes to decide:
|
||||
* - REFUSE at submit when tool-calling is unsupported (D6 — useless loop)
|
||||
* - WARN at submit when prompt caching is unavailable (D6 — cost regression)
|
||||
* - INFO at submit when parallel tools unsupported (D6 — just slower)
|
||||
*
|
||||
* The capability shape is intentionally narrow. Per-call cost is already in
|
||||
* `ChatTouchpoint.cost_per_1m_*`; we don't re-export it here because routing
|
||||
* decisions don't depend on it.
|
||||
*/
|
||||
|
||||
import { resolveRecipe } from './model-resolver.ts';
|
||||
import { AIConfigError } from './errors.ts';
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** Provider returns native function/tool calling. Required for the subagent loop. */
|
||||
supportsToolCalling: boolean;
|
||||
|
||||
/**
|
||||
* Anthropic-style ephemeral prompt cache markers honored. When false, the
|
||||
* loop runs hot (no cache_control injection) and per-turn costs scale
|
||||
* linearly with conversation length. Doesn't break the loop; just costs more.
|
||||
*/
|
||||
supportsPromptCaching: boolean;
|
||||
|
||||
/**
|
||||
* Provider can return multiple `tool_use` blocks in a single assistant turn
|
||||
* and accepts a single follow-up `user` message with matching `tool_result`
|
||||
* blocks. When false, the loop falls back to serial tool dispatch (one tool
|
||||
* per turn), which matches the v0.15 default and is a perf hit, not a
|
||||
* correctness issue.
|
||||
*
|
||||
* NOTE: this currently reads from `recipe.touchpoints.chat.supports_tools`
|
||||
* because no recipe exposes a separate parallel-tools field today. Treat as
|
||||
* "best-effort capability hint" — when the gateway tool loop lands in v0.38
|
||||
* Slice 4 it will add parallel dispatch with a per-recipe gate.
|
||||
*/
|
||||
supportsParallelTools: boolean;
|
||||
|
||||
/**
|
||||
* Provider supports an extended-thinking / reasoning block in responses.
|
||||
* Not load-bearing for the loop; surfaced so callers (e.g. `gbrain agent run`)
|
||||
* can decide whether to surface the reasoning trace in `--follow` output.
|
||||
*/
|
||||
supportsThinking: boolean;
|
||||
|
||||
/**
|
||||
* Max input+output tokens the provider/model accepts per turn. Drives the
|
||||
* gateway's pre-flight context check; the loop refuses to send a prompt
|
||||
* that exceeds this (with a paste-ready truncation hint).
|
||||
*/
|
||||
maxContext: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `provider:model` string and return its capability set.
|
||||
*
|
||||
* Throws `AIConfigError` when the provider/model is unknown OR when the
|
||||
* provider lacks a `chat` touchpoint (e.g., embedding-only providers like
|
||||
* Voyage). Callers that want a soft check can wrap in try/catch and degrade.
|
||||
*/
|
||||
export function getProviderCapabilities(modelString: string): ProviderCapabilities {
|
||||
const { recipe, parsed } = resolveRecipe(modelString);
|
||||
const chat = recipe.touchpoints.chat;
|
||||
if (!chat) {
|
||||
throw new AIConfigError(
|
||||
`Provider "${recipe.id}" does not offer a chat touchpoint.`,
|
||||
`Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`,
|
||||
);
|
||||
}
|
||||
|
||||
// For native providers, the model must be in the recipe's allow-list. For
|
||||
// openai-compatible recipes (litellm, ollama, llama-server), arbitrary model
|
||||
// ids are accepted because the gateway behind the proxy decides what's real.
|
||||
// We don't error here — `assertTouchpoint` already enforces this at gateway
|
||||
// boundary; this function returns capabilities for whatever the user asked
|
||||
// for, on the assumption it'll be validated elsewhere.
|
||||
|
||||
return {
|
||||
supportsToolCalling: chat.supports_tools === true,
|
||||
supportsPromptCaching: chat.supports_prompt_cache === true,
|
||||
// No recipe exposes parallel-tools-specifically yet; gate on supports_tools.
|
||||
// Subsequent waves can split this into its own recipe field if a provider
|
||||
// ever supports tools without parallel dispatch.
|
||||
supportsParallelTools: chat.supports_tools === true,
|
||||
// Not exposed by ChatTouchpoint today — defaults to false. Recipes can add
|
||||
// a `supports_thinking` field later without breaking this helper (it'll
|
||||
// just keep returning false until a recipe sets it).
|
||||
supportsThinking: false,
|
||||
maxContext: chat.max_context_tokens ?? 128_000,
|
||||
};
|
||||
|
||||
// The `parsed` binding is intentionally unused — `resolveRecipe` is called
|
||||
// here for its validation side-effects (throws on unknown provider). Keeping
|
||||
// the destructure makes future per-model capability overrides cheap.
|
||||
void parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-1 gate consumed by `enforceSubagentCapable()` in src/core/model-config.ts
|
||||
* (D6 + D7). Returns:
|
||||
*
|
||||
* - `'ok'` — provider has tool-calling, prompt caching, and parallel tools.
|
||||
* Loop runs at full speed.
|
||||
* - `'degraded:no_caching'` — provider supports tools but lacks prompt
|
||||
* caching. Loop runs but per-turn cost is higher. Warn once per
|
||||
* (source, model) pair.
|
||||
* - `'degraded:no_parallel'` — provider supports tools and caching but the
|
||||
* loop will dispatch serially. Info-log; no warn.
|
||||
* - `'unusable:no_tools'` — provider lacks tool calling entirely. Refuse at
|
||||
* submit; the loop has no way to execute brain ops.
|
||||
* - `'unknown'` — the provider/model isn't in any recipe. Refuse at submit
|
||||
* (defensive: don't spend money on an unrecognized provider).
|
||||
*
|
||||
* Pure function; no side effects. The caller decides what to do with each
|
||||
* verdict (warn / info / throw) based on its surface.
|
||||
*/
|
||||
export type CapabilityVerdict =
|
||||
| 'ok'
|
||||
| 'degraded:no_caching'
|
||||
| 'degraded:no_parallel'
|
||||
| 'unusable:no_tools'
|
||||
| 'unknown';
|
||||
|
||||
export function classifyCapabilities(modelString: string): CapabilityVerdict {
|
||||
let caps: ProviderCapabilities;
|
||||
try {
|
||||
caps = getProviderCapabilities(modelString);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
if (!caps.supportsToolCalling) return 'unusable:no_tools';
|
||||
if (!caps.supportsPromptCaching) return 'degraded:no_caching';
|
||||
if (!caps.supportsParallelTools) return 'degraded:no_parallel';
|
||||
return 'ok';
|
||||
}
|
||||
@@ -3766,6 +3766,66 @@ export const MIGRATIONS: Migration[] = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 81,
|
||||
name: 'subagent_tool_executions_stable_id',
|
||||
// v0.38 Slice 1 — D11 stable-ID columns. The pre-v81 reconciliation key
|
||||
// for crash-replay was `(job_id, tool_use_id)` where tool_use_id came from
|
||||
// the Anthropic Messages API. That was load-bearing for the v0.15 crash-
|
||||
// replay guarantee but tied the loop to Anthropic-direct. The provider-
|
||||
// agnostic loop assigns its own ordinal at FIRST observation of a tool
|
||||
// call in a turn; that ordinal + gbrain_tool_use_id (uuid v7) become the
|
||||
// canonical reconciliation key. The provider's own tool_use_id stays as a
|
||||
// side channel for debugging.
|
||||
//
|
||||
// Migration shape:
|
||||
// - ordinal INTEGER NULL: legacy rows have NULL; new writes always set
|
||||
// a value. UNIQUE on (job_id, message_idx, ordinal) treats multiple
|
||||
// NULL ordinals as distinct (Postgres semantics), so legacy rows
|
||||
// don't collide with each other or with new writes.
|
||||
// - gbrain_tool_use_id UUID NULL: same NULL semantics; populated at
|
||||
// write time post-Slice-1, NULL on legacy rows.
|
||||
// - Existing constraint uniq_subagent_tools_use_id (job_id, tool_use_id)
|
||||
// is preserved — provider IDs stay deduplicated within a job.
|
||||
// - The read-time D5 shim recomputes a stable key for legacy rows from
|
||||
// (job_id, message_idx, content_blocks index, tool_name); no data
|
||||
// migration needed.
|
||||
//
|
||||
// ADD COLUMN with no DEFAULT (NULL) is metadata-only on Postgres 11+ and
|
||||
// PGLite 17.5 — instant on tables of any size. The UNIQUE constraint
|
||||
// builds a partial index; on a typical brain with ~hundreds of subagent
|
||||
// rows this is sub-millisecond.
|
||||
idempotent: true,
|
||||
sql: `
|
||||
ALTER TABLE subagent_tool_executions
|
||||
ADD COLUMN IF NOT EXISTS ordinal INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'subagent_tool_executions_stable_id'
|
||||
) THEN
|
||||
ALTER TABLE subagent_tool_executions
|
||||
ADD CONSTRAINT subagent_tool_executions_stable_id
|
||||
UNIQUE (job_id, message_idx, ordinal);
|
||||
END IF;
|
||||
END$$;
|
||||
`,
|
||||
sqlFor: {
|
||||
// PGLite doesn't support DO blocks. Use a simpler conditional via
|
||||
// information_schema with a separate idempotent guard.
|
||||
pglite: `
|
||||
ALTER TABLE subagent_tool_executions
|
||||
ADD COLUMN IF NOT EXISTS ordinal INTEGER;
|
||||
ALTER TABLE subagent_tool_executions
|
||||
ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID;
|
||||
ALTER TABLE subagent_tool_executions
|
||||
ADD CONSTRAINT subagent_tool_executions_stable_id
|
||||
UNIQUE (job_id, message_idx, ordinal);
|
||||
`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
+20
-13
@@ -409,20 +409,27 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_job ON subagent_messages (job_i
|
||||
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (job_id, provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first observation;
|
||||
-- gbrain_tool_use_id is uuid v7). Reconciliation on crash-replay uses
|
||||
-- (job_id, message_idx, ordinal) as the unique key. Legacy rows (pre-v81)
|
||||
-- have ordinal=NULL and resolve via the read-time D5 shim.
|
||||
ordinal INTEGER,
|
||||
gbrain_tool_use_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
|
||||
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
|
||||
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
|
||||
|
||||
+22
-13
@@ -727,20 +727,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (
|
||||
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
|
||||
-- Replay re-runs 'pending' rows only if the tool is idempotent.
|
||||
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first
|
||||
-- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation
|
||||
-- on crash-replay uses (job_id, message_idx, ordinal) as the unique key.
|
||||
-- Legacy rows (pre-v81) have ordinal=NULL + gbrain_tool_use_id=NULL and
|
||||
-- are resolved by the read-time D5 shim that recomputes the key from
|
||||
-- (job_id, message_idx, content_blocks index, tool_name).
|
||||
ordinal INTEGER,
|
||||
gbrain_tool_use_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
|
||||
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
|
||||
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
|
||||
|
||||
+22
-13
@@ -723,20 +723,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (
|
||||
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
|
||||
-- Replay re-runs 'pending' rows only if the tool is idempotent.
|
||||
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
||||
message_idx INTEGER NOT NULL,
|
||||
tool_use_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
input JSONB NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
output JSONB,
|
||||
error TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
provider_id TEXT,
|
||||
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first
|
||||
-- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation
|
||||
-- on crash-replay uses (job_id, message_idx, ordinal) as the unique key.
|
||||
-- Legacy rows (pre-v81) have ordinal=NULL + gbrain_tool_use_id=NULL and
|
||||
-- are resolved by the read-time D5 shim that recomputes the key from
|
||||
-- (job_id, message_idx, content_blocks index, tool_name).
|
||||
ordinal INTEGER,
|
||||
gbrain_tool_use_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
|
||||
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
|
||||
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { getProviderCapabilities, classifyCapabilities } from '../../src/core/ai/capabilities.ts';
|
||||
|
||||
describe('getProviderCapabilities (v0.38 Slice 1 — D6/D7 recipe-driven capabilities)', () => {
|
||||
it('returns full capabilities for Anthropic (canonical reference)', () => {
|
||||
const caps = getProviderCapabilities('anthropic:claude-sonnet-4-6');
|
||||
expect(caps.supportsToolCalling).toBe(true);
|
||||
expect(caps.supportsPromptCaching).toBe(true);
|
||||
expect(caps.supportsParallelTools).toBe(true);
|
||||
expect(caps.maxContext).toBe(200000);
|
||||
});
|
||||
|
||||
it('returns capabilities for OpenAI (no prompt caching field set as true)', () => {
|
||||
const caps = getProviderCapabilities('openai:gpt-5.2');
|
||||
expect(caps.supportsToolCalling).toBe(true);
|
||||
expect(caps.supportsPromptCaching).toBe(false); // OpenAI implicit caching doesn't get marked
|
||||
expect(caps.maxContext).toBe(200000);
|
||||
});
|
||||
|
||||
it('returns capabilities for Google Gemini', () => {
|
||||
const caps = getProviderCapabilities('google:gemini-1.5-pro');
|
||||
expect(caps.supportsToolCalling).toBe(true);
|
||||
expect(caps.supportsPromptCaching).toBe(false);
|
||||
expect(caps.maxContext).toBe(1000000); // Gemini 1.5 Pro
|
||||
});
|
||||
|
||||
it('honors Anthropic alias (undated → dated)', () => {
|
||||
const caps = getProviderCapabilities('anthropic:claude-haiku-4-5');
|
||||
expect(caps.supportsToolCalling).toBe(true);
|
||||
});
|
||||
|
||||
it('throws for unknown provider', () => {
|
||||
expect(() => getProviderCapabilities('madeup-provider:foo')).toThrow();
|
||||
});
|
||||
|
||||
it('throws for embedding-only provider (no chat touchpoint)', () => {
|
||||
expect(() => getProviderCapabilities('voyage:voyage-3-large')).toThrow(
|
||||
/does not offer a chat touchpoint/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for missing colon', () => {
|
||||
expect(() => getProviderCapabilities('claude-sonnet-4-6')).toThrow(/missing a provider prefix/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyCapabilities (D6 — three-tier capability verdict)', () => {
|
||||
it('returns ok for fully-capable Anthropic models', () => {
|
||||
expect(classifyCapabilities('anthropic:claude-sonnet-4-6')).toBe('ok');
|
||||
expect(classifyCapabilities('anthropic:claude-opus-4-7')).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns degraded:no_caching for OpenAI (tools yes, caching no)', () => {
|
||||
expect(classifyCapabilities('openai:gpt-5.2')).toBe('degraded:no_caching');
|
||||
});
|
||||
|
||||
it('returns degraded:no_caching for Google Gemini', () => {
|
||||
expect(classifyCapabilities('google:gemini-1.5-pro')).toBe('degraded:no_caching');
|
||||
});
|
||||
|
||||
it('returns unknown for unrecognized providers', () => {
|
||||
expect(classifyCapabilities('madeup:something')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown for embedding-only providers (chat touchpoint missing)', () => {
|
||||
// Voyage has no chat touchpoint → throws inside getProviderCapabilities
|
||||
// → classifyCapabilities catches → returns 'unknown'.
|
||||
expect(classifyCapabilities('voyage:voyage-3-large')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user