From 97f8b2f07358a9c048e6013d857c4f38ed065bfe Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 15:48:43 -0300 Subject: [PATCH 01/13] docs(adapters): publish canonical provider swap recipe --- .changeset/bright-providers-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-providers-switch.md diff --git a/.changeset/bright-providers-switch.md b/.changeset/bright-providers-switch.md new file mode 100644 index 00000000..7bca9bcc --- /dev/null +++ b/.changeset/bright-providers-switch.md @@ -0,0 +1,5 @@ +--- +'@agentskit/adapters': patch +--- + +Add a tested provider-swap recipe for OpenAI, Anthropic, Gemini, OpenRouter, Groq, and Ollama, and align provider documentation with the adapters' supported configuration. From 3e582bb0b365b241f6144f160a11d6be18264b30 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 16:13:58 -0300 Subject: [PATCH 02/13] fix(adapters): keep provider recipes executable --- .changeset/bright-providers-switch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-providers-switch.md b/.changeset/bright-providers-switch.md index 7bca9bcc..814cb6d0 100644 --- a/.changeset/bright-providers-switch.md +++ b/.changeset/bright-providers-switch.md @@ -2,4 +2,4 @@ '@agentskit/adapters': patch --- -Add a tested provider-swap recipe for OpenAI, Anthropic, Gemini, OpenRouter, Groq, and Ollama, and align provider documentation with the adapters' supported configuration. +Add a tested provider-swap recipe for OpenAI, Anthropic, Gemini, OpenRouter, Groq, and Ollama, align provider documentation with the adapters' supported configuration, and migrate Groq's default from the retiring Llama 3.3 model to `openai/gpt-oss-120b`. From 95e37fc717aca86a2d91ff718150ca1b830fae5b Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 19:21:54 -0300 Subject: [PATCH 03/13] test(adapters): make contract harness transport-injectable --- packages/adapters/tests/contract.test.ts | 67 ++++++--- packages/adapters/tests/contract.ts | 175 +++++++++++++++++++---- 2 files changed, 193 insertions(+), 49 deletions(-) diff --git a/packages/adapters/tests/contract.test.ts b/packages/adapters/tests/contract.test.ts index 5ab7dfda..fb84d430 100644 --- a/packages/adapters/tests/contract.test.ts +++ b/packages/adapters/tests/contract.test.ts @@ -1,9 +1,11 @@ import { runAdapterContract, + runFetchAdapterContract, openAISuccessBody, anthropicSuccessBody, geminiSuccessBody, ollamaSuccessBody, + createInjectedContractTransport, } from './contract' import { openai, @@ -38,26 +40,26 @@ const noRetry = { maxAttempts: 1, sleep: async () => {} } // One contract block per adapter — all OpenAI-compatible adapters share the // same fetch shape, so they share the same successBody factory. -runAdapterContract({ name: 'openai', build: () => openai({ apiKey: 'k', model: 'gpt-4o-mini', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'anthropic', build: () => anthropic({ apiKey: 'k', model: 'claude-sonnet-4-6', retry: noRetry }), successBody: anth }) -runAdapterContract({ name: 'gemini', build: () => gemini({ apiKey: 'k', model: 'gemini-2.5-flash', retry: noRetry }), successBody: gem }) -runAdapterContract({ name: 'grok', build: () => grok({ apiKey: 'k', model: 'grok-2', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'deepseek', build: () => deepseek({ apiKey: 'k', model: 'deepseek-chat', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'kimi', build: () => kimi({ apiKey: 'k', model: 'moonshot-v1-8k', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'mistral', build: () => mistral({ apiKey: 'k', model: 'mistral-small-latest', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'cohere', build: () => cohere({ apiKey: 'k', model: 'command-r-plus', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'together', build: () => together({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'groq', build: () => groq({ apiKey: 'k', model: 'llama-3.3-70b-versatile', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'fireworks', build: () => fireworks({ apiKey: 'k', model: 'accounts/fireworks/models/llama-v3-70b-instruct', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'openrouter', build: () => openrouter({ apiKey: 'k', model: 'meta-llama/llama-3-70b', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'huggingface',build: () => huggingface({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'ollama', build: () => ollama({ model: 'llama3.1', retry: noRetry }), successBody: oll }) -runAdapterContract({ name: 'lmstudio', build: () => lmstudio({ apiKey: 'lm-studio', model: 'local-model', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'vllm', build: () => vllm({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'llamacpp', build: () => llamacpp({ apiKey: 'k', model: 'local', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'cerebras', build: () => cerebras({ apiKey: 'k', retry: noRetry }), successBody: oai }) -runAdapterContract({ name: 'bail/qwen', build: () => bail({ apiKey: 'k', retry: noRetry }), successBody: oai }) -runAdapterContract({ +runFetchAdapterContract({ name: 'openai', build: () => openai({ apiKey: 'k', model: 'gpt-4o-mini', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'anthropic', build: () => anthropic({ apiKey: 'k', model: 'claude-sonnet-4-6', retry: noRetry }), successBody: anth }) +runFetchAdapterContract({ name: 'gemini', build: () => gemini({ apiKey: 'k', model: 'gemini-2.5-flash', retry: noRetry }), successBody: gem }) +runFetchAdapterContract({ name: 'grok', build: () => grok({ apiKey: 'k', model: 'grok-2', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'deepseek', build: () => deepseek({ apiKey: 'k', model: 'deepseek-chat', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'kimi', build: () => kimi({ apiKey: 'k', model: 'moonshot-v1-8k', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'mistral', build: () => mistral({ apiKey: 'k', model: 'mistral-small-latest', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'cohere', build: () => cohere({ apiKey: 'k', model: 'command-r-plus', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'together', build: () => together({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'groq', build: () => groq({ apiKey: 'k', model: 'llama-3.3-70b-versatile', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'fireworks', build: () => fireworks({ apiKey: 'k', model: 'accounts/fireworks/models/llama-v3-70b-instruct', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'openrouter', build: () => openrouter({ apiKey: 'k', model: 'meta-llama/llama-3-70b', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'huggingface',build: () => huggingface({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'ollama', build: () => ollama({ model: 'llama3.1', retry: noRetry }), successBody: oll }) +runFetchAdapterContract({ name: 'lmstudio', build: () => lmstudio({ apiKey: 'lm-studio', model: 'local-model', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'vllm', build: () => vllm({ apiKey: 'k', model: 'meta-llama/Llama-3-70b', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'llamacpp', build: () => llamacpp({ apiKey: 'k', model: 'local', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'cerebras', build: () => cerebras({ apiKey: 'k', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'bail/qwen', build: () => bail({ apiKey: 'k', retry: noRetry }), successBody: oai }) +runFetchAdapterContract({ name: 'azureOpenAI', build: () => azureOpenAI({ apiKey: 'k', @@ -68,6 +70,31 @@ runAdapterContract({ successBody: oai, }) +const injectedTransport = createInjectedContractTransport() +runAdapterContract({ + name: 'injected transport fixture', + transport: injectedTransport, + successBody: oai, + build: () => ({ + createSource: () => { + const controller = new AbortController() + return { + stream: async function* () { + try { + for await (const chunk of injectedTransport.stream(controller.signal)) yield chunk + } catch (error) { + yield { + type: 'error', + metadata: { error: error instanceof Error ? error : new Error(String(error)) }, + } + } + }, + abort: () => controller.abort(), + } + }, + }), +}) + // Adapters that need their own contract surface (different fetch shape, SDK // peer dep, or two-step protocol) live in dedicated test files: // - bedrock → tests/bedrock.test.ts (uses an injected SDK client) diff --git a/packages/adapters/tests/contract.ts b/packages/adapters/tests/contract.ts index 9aeb697e..83199ae1 100644 --- a/packages/adapters/tests/contract.ts +++ b/packages/adapters/tests/contract.ts @@ -2,14 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { AdapterFactory, StreamChunk } from '@agentskit/core' /** - * Contract test harness from ADR 0001 — runs invariants A1–A10 against any - * adapter that goes through `globalThis.fetch`. Bring your own fetch stub: - * for OpenAI-compatible adapters, return an SSE body with a `[DONE]` - * sentinel; for Anthropic-style, return its event stream; etc. - * - * Adapters that don't use the global fetch (bedrock, replicate, langchain, - * vercel-ai) need their own per-adapter test files — this harness skips - * them. + * Contract test harness from ADR 0001 — runs invariants A1–A10 through an + * explicit transport driver. Fetch-backed adapters use the stock driver; + * SDK- or client-backed adapters can inject a driver without being silently + * skipped. */ export interface ContractStubResponse { /** Raw body of the streaming response. Will be passed back as `Response.body`. */ @@ -18,12 +14,29 @@ export interface ContractStubResponse { contentType?: string } +/** + * Transport seam used by the contract harness. The default fetch-backed + * driver keeps existing adapters unchanged; adapters backed by an SDK or + * another client can provide their own driver instead of silently skipping + * the shared contract suite. + */ +export interface ContractTransport { + installProbe(): void + installSuccess(stub: ContractStubResponse): void + installFailure(): void + installAbortable(stub: ContractStubResponse): void + callCount(): number + restore(originalFetch: typeof globalThis.fetch): void +} + export interface ContractAdapterCase { name: string /** Construct the adapter under test. */ build(): AdapterFactory /** Provider-shaped success body. */ successBody(): ContractStubResponse + /** Explicit transport driver; no adapter is silently skipped. */ + transport: ContractTransport } function bodyToStream(body: string | Uint8Array | ReadableStream): ReadableStream { @@ -81,15 +94,108 @@ function delayedAbortableStream( }) } -function mockSuccess(stub: ContractStubResponse): typeof globalThis.fetch { - return vi.fn(async () => new Response(bodyToStream(stub.body), { +function responseFor(stub: ContractStubResponse): Response { + return new Response(bodyToStream(stub.body), { status: stub.status ?? 200, headers: { 'content-type': stub.contentType ?? 'text/event-stream' }, - })) as unknown as typeof globalThis.fetch + }) +} + +function installFetch( + handler: (input: RequestInfo | URL, init?: RequestInit) => Promise, + onCall: () => void, +): void { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + onCall() + return handler(input, init) + }) as unknown as typeof globalThis.fetch } -function mockFailure(): typeof globalThis.fetch { - return vi.fn(async () => new Response('upstream broke', { status: 500 })) as unknown as typeof globalThis.fetch +/** Create the explicit driver used by fetch-backed adapters. */ +export function createFetchContractTransport(): ContractTransport { + let calls = 0 + + const install = (handler: (input: RequestInfo | URL, init?: RequestInit) => Promise) => { + calls = 0 + installFetch(handler, () => { calls += 1 }) + } + + return { + installProbe() { + install(async () => new Response(null, { status: 204 })) + }, + installSuccess(stub) { + install(async () => responseFor(stub)) + }, + installFailure() { + install(async () => new Response('upstream broke', { status: 500 })) + }, + installAbortable(stub) { + install(async (_input, init) => { + const raw = stub.body + if (raw instanceof ReadableStream) return responseFor(stub) + return new Response(delayedAbortableStream(raw, init?.signal), { + status: stub.status ?? 200, + headers: { 'content-type': stub.contentType ?? 'text/event-stream' }, + }) + }) + }, + callCount() { + return calls + }, + restore(originalFetch) { + globalThis.fetch = originalFetch + calls = 0 + }, + } +} + +/** + * Small deterministic non-fetch driver used to prove the seam itself. It is + * intentionally test-only: production adapters remain responsible for + * adapting their SDK/client streams to the core contract. + */ +export interface InjectedContractTransport extends ContractTransport { + stream(signal: AbortSignal): AsyncIterableIterator +} + +export function createInjectedContractTransport(): InjectedContractTransport { + type Scenario = 'probe' | 'success' | 'failure' | 'abortable' + let scenario: Scenario = 'probe' + let calls = 0 + + const reset = (next: Scenario) => { + scenario = next + calls = 0 + } + + return { + installProbe() { reset('probe') }, + installSuccess() { reset('success') }, + installFailure() { reset('failure') }, + installAbortable() { reset('abortable') }, + callCount() { return calls }, + restore() { reset('probe') }, + async *stream(signal) { + calls += 1 + if (scenario === 'failure') throw new Error('upstream broke') + if (scenario === 'abortable') { + yield { type: 'text', content: 'hi' } + await new Promise((resolve) => { + const timer = setTimeout(resolve, 25) + signal.addEventListener('abort', () => { + clearTimeout(timer) + resolve() + }, { once: true }) + }) + if (signal.aborted) return + yield { type: 'done' } + return + } + yield { type: 'text', content: 'hi' } + yield { type: 'done' } + }, + } } function userMessage(content: string) { @@ -119,24 +225,29 @@ function isTerminal(chunk: StreamChunk | undefined): boolean { * `describe(...)` so vitest's `beforeEach` / `afterEach` scope correctly. */ export function runAdapterContract(adapterCase: ContractAdapterCase): void { + if (!adapterCase.transport) { + throw new Error(`adapter contract "${adapterCase.name}" requires an explicit transport driver`) + } + describe(`adapter contract — ${adapterCase.name}`, () => { let originalFetch: typeof globalThis.fetch - beforeEach(() => { originalFetch = globalThis.fetch }) - afterEach(() => { globalThis.fetch = originalFetch }) + beforeEach(() => { + originalFetch = globalThis.fetch + adapterCase.transport.installProbe() + }) + afterEach(() => { adapterCase.transport.restore(originalFetch) }) it('A1: createSource is synchronous and does not fetch eagerly', () => { - const fetchSpy = vi.fn() as unknown as typeof globalThis.fetch - globalThis.fetch = fetchSpy const factory = adapterCase.build() const source = factory.createSource({ messages: [userMessage('hi')] }) expect(source).toBeDefined() expect(source.stream).toBeTypeOf('function') expect(source.abort).toBeTypeOf('function') - expect((fetchSpy as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(0) + expect(adapterCase.transport.callCount()).toBe(0) }) it('A3 + A4: stream ends with exactly one terminal chunk (done or error)', async () => { - globalThis.fetch = mockSuccess(adapterCase.successBody()) + adapterCase.transport.installSuccess(adapterCase.successBody()) const out = await drain(adapterCase.build()) const terminals = out.filter(c => c.type === 'done' || c.type === 'error') expect(terminals).toHaveLength(1) @@ -144,13 +255,13 @@ export function runAdapterContract(adapterCase: ContractAdapterCase): void { }) it('A6: abort is safe before stream() is called', () => { - globalThis.fetch = mockSuccess(adapterCase.successBody()) + adapterCase.transport.installSuccess(adapterCase.successBody()) const source = adapterCase.build().createSource({ messages: [userMessage('hi')] }) expect(() => source.abort()).not.toThrow() }) it('A6: abort is safe after stream() completes', async () => { - globalThis.fetch = mockSuccess(adapterCase.successBody()) + adapterCase.transport.installSuccess(adapterCase.successBody()) const source = adapterCase.build().createSource({ messages: [userMessage('hi')] }) for await (const _ of source.stream()) { /* drain */ void _ } expect(() => source.abort()).not.toThrow() @@ -163,12 +274,7 @@ export function runAdapterContract(adapterCase: ContractAdapterCase): void { // Skip exotic bodies; all stock cases use string bodies. return } - globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - return new Response(delayedAbortableStream(raw, init?.signal), { - status: stub.status ?? 200, - headers: { 'content-type': stub.contentType ?? 'text/event-stream' }, - }) - }) as unknown as typeof globalThis.fetch + adapterCase.transport.installAbortable(stub) const source = adapterCase.build().createSource({ messages: [userMessage('hi')] }) const iter = source.stream()[Symbol.asyncIterator]() @@ -186,7 +292,7 @@ export function runAdapterContract(adapterCase: ContractAdapterCase): void { }) it('A7: input messages are not mutated', async () => { - globalThis.fetch = mockSuccess(adapterCase.successBody()) + adapterCase.transport.installSuccess(adapterCase.successBody()) const messages = [userMessage('hi')] const snapshot = JSON.stringify(messages) for await (const _ of adapterCase.build().createSource({ messages }).stream()) { void _ } @@ -194,7 +300,7 @@ export function runAdapterContract(adapterCase: ContractAdapterCase): void { }) it('A9: upstream failure yields exactly one error chunk with metadata.error', async () => { - globalThis.fetch = mockFailure() + adapterCase.transport.installFailure() const out: StreamChunk[] = [] // Must not throw — caller drains, contract says no rejection here. let rejected: unknown @@ -216,6 +322,17 @@ export function runAdapterContract(adapterCase: ContractAdapterCase): void { }) } +/** + * Convenience wrapper for the stock adapters that use global fetch. Keeping + * this explicit at each call site makes a missing transport driver fail at + * type-check time instead of becoming an untested adapter. + */ +export function runFetchAdapterContract( + adapterCase: Omit, +): void { + runAdapterContract({ ...adapterCase, transport: createFetchContractTransport() }) +} + // --------------------------------------------------------------------------- // Stock provider response bodies // --------------------------------------------------------------------------- From a981c838ab10e637525d2b4662fd019b08f9d511 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 19:51:40 -0300 Subject: [PATCH 04/13] test(adapters): cover catalog capability matrix --- .../tests/catalog/capability-matrix.test.ts | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 packages/adapters/tests/catalog/capability-matrix.test.ts diff --git a/packages/adapters/tests/catalog/capability-matrix.test.ts b/packages/adapters/tests/catalog/capability-matrix.test.ts new file mode 100644 index 00000000..eabbd1c2 --- /dev/null +++ b/packages/adapters/tests/catalog/capability-matrix.test.ts @@ -0,0 +1,221 @@ +import { createAjvValidator } from '@agentskit/tools/validation' +import type { + AdapterFactory, + AdapterRequest, + StreamChunk, +} from '@agentskit/core' +import { describe, expect, it, vi } from 'vitest' +import { + applyOverrides, + catalog, + catalogSnapshotSchema, +} from '../../src/catalog' +import type { CatalogModel, CatalogSnapshot } from '../../src/catalog' +import { createRouter as createAdapterRouter } from '../../src/router' + +interface CapabilityMatrixRow { + providerId: string + modelId: string + name: string + family?: string + limit?: CatalogModel['limit'] + cost?: CatalogModel['cost'] + modalities?: CatalogModel['modalities'] + toolCall: boolean + structuredOutput: boolean + reasoning: boolean + attachment: boolean + openWeights: boolean + knowledge?: string + releaseDate?: string + lastUpdated?: string + deprecated?: boolean +} + +const validate = createAjvValidator({ rejectAdditionalProperties: true }) + +function buildCapabilityMatrix(snapshot: CatalogSnapshot): CapabilityMatrixRow[] { + const result = validate(catalogSnapshotSchema, snapshot as unknown as Record) + if (!result.valid) { + throw new Error(`catalog snapshot does not match its schema: ${result.message ?? 'unknown error'}`) + } + + return snapshot.providers.flatMap(provider => provider.models.map(model => ({ + providerId: provider.id, + modelId: model.id, + name: model.name, + family: model.family, + limit: model.limit, + cost: model.cost, + modalities: model.modalities, + toolCall: model.toolCall, + structuredOutput: model.structuredOutput, + reasoning: model.reasoning, + attachment: model.attachment, + openWeights: model.openWeights, + knowledge: model.knowledge, + releaseDate: model.releaseDate, + lastUpdated: model.lastUpdated, + deprecated: model.deprecated, + }))) +} + +const matrixFixture: CatalogSnapshot = { + schemaVersion: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + source: { name: 'models.dev', url: 'https://models.dev/api.json', version: 'test' }, + providers: [{ + id: 'matrix-provider', + name: 'Matrix Provider', + env: ['MATRIX_API_KEY'], + openaiCompatible: true, + models: [ + { + id: 'tools-model', + name: 'Tools Model', + family: 'tools', + limit: { context: 128_000, output: 8_192 }, + cost: { input: 1, output: 2 }, + modalities: { input: ['text'], output: ['text'] }, + toolCall: true, + structuredOutput: true, + reasoning: false, + attachment: false, + openWeights: false, + knowledge: '2025-01', + releaseDate: '2025-01-01', + lastUpdated: '2026-01-01', + }, + { + id: 'legacy-model', + name: 'Legacy Model', + toolCall: false, + structuredOutput: false, + reasoning: false, + attachment: false, + openWeights: false, + deprecated: true, + }, + ], + }], +} + +function requestWithTools(): AdapterRequest { + return { + messages: [{ + id: '1', + role: 'user', + content: 'hi', + status: 'complete', + createdAt: new Date(0), + }], + context: { + tools: [{ + name: 'lookup', + description: 'Look up a value', + schema: { type: 'object' }, + execute: async () => ({}), + }], + }, + } +} + +function fakeAdapter(id: string): AdapterFactory { + return { + createSource: () => ({ + abort: () => {}, + stream: async function* () { + yield { type: 'text', content: id } as StreamChunk + yield { type: 'done' } as StreamChunk + }, + }), + } +} + +describe('catalog capability matrix', () => { + it('has one complete row per committed-snapshot model', () => { + const rows = buildCapabilityMatrix(catalog) + const modelCount = catalog.providers.reduce((count, provider) => count + provider.models.length, 0) + + expect(rows).toHaveLength(modelCount) + expect(rows.every(row => + typeof row.toolCall === 'boolean' && + typeof row.structuredOutput === 'boolean' && + typeof row.reasoning === 'boolean' && + typeof row.attachment === 'boolean' && + typeof row.openWeights === 'boolean', + )).toBe(true) + expect(rows.some(row => row.modalities || row.limit || row.cost)).toBe(true) + }) + + it('is snapshot-only and never fetches while building', () => { + const originalFetch = globalThis.fetch + const fetchSpy = vi.fn() + globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch + try { + buildCapabilityMatrix(catalog) + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + globalThis.fetch = originalFetch + } + }) + + it('fails closed when the snapshot diverges from its schema', () => { + const invalid = structuredClone(matrixFixture) as CatalogSnapshot + const model = invalid.providers[0]!.models[0]! + invalid.providers[0]!.models[0] = { ...model, toolCall: undefined as unknown as boolean } + + expect(() => buildCapabilityMatrix(invalid)).toThrow(/does not match its schema/) + }) + + it('preserves deprecation and lifecycle metadata without rewriting model ids', () => { + const rows = buildCapabilityMatrix(matrixFixture) + const legacy = rows.find(row => row.modelId === 'legacy-model') + + expect(legacy?.deprecated).toBe(true) + expect(legacy?.modelId).toBe('legacy-model') + expect(rows.find(row => row.modelId === 'tools-model')).toMatchObject({ + knowledge: '2025-01', + releaseDate: '2025-01-01', + lastUpdated: '2026-01-01', + }) + }) + + it('feeds capability-match from the same matrix and honors model overrides', async () => { + const rows = buildCapabilityMatrix(matrixFixture) + const router = createAdapterRouter({ + policy: 'capability-match', + candidates: rows.map(row => ({ + id: `${row.providerId}/${row.modelId}`, + adapter: fakeAdapter(row.modelId), + capabilities: { tools: row.toolCall }, + cost: row.cost?.input, + })), + }) + + const selected = router.createSource(requestWithTools()) + expect(selected).toBeDefined() + const selectedText: string[] = [] + for await (const chunk of selected.stream()) { + if (chunk.type === 'text' && chunk.content) selectedText.push(chunk.content) + } + expect(selectedText).toEqual(['tools-model']) + + const overridden = applyOverrides(matrixFixture, { + allowedModels: { 'matrix-provider': ['legacy-model'] }, + }) + const overriddenRows = buildCapabilityMatrix(overridden) + const overriddenRouter = createAdapterRouter({ + policy: 'capability-match', + candidates: overriddenRows.map(row => ({ + id: `${row.providerId}/${row.modelId}`, + adapter: fakeAdapter(row.modelId), + capabilities: { tools: row.toolCall }, + })), + }) + + expect(overriddenRows).toHaveLength(1) + expect(overriddenRows[0]?.toolCall).toBe(false) + expect(() => overriddenRouter.createSource(requestWithTools())).toThrow(/no candidate satisfies/) + }) +}) From a37bb4e044e2f3da5cdc832de0620fffa0948c7f Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 20:01:52 -0300 Subject: [PATCH 05/13] feat(cli): centralize doctor provider registry --- .changeset/steady-doctors-check.md | 5 + packages/cli/src/commands/doctor.ts | 3 +- packages/cli/src/doctor.ts | 63 +++--- packages/cli/src/provider-registry.ts | 225 +++++++++++++++++++ packages/cli/tests/doctor.test.ts | 47 ++++ packages/cli/tests/provider-registry.test.ts | 46 ++++ 6 files changed, 359 insertions(+), 30 deletions(-) create mode 100644 .changeset/steady-doctors-check.md create mode 100644 packages/cli/src/provider-registry.ts create mode 100644 packages/cli/tests/provider-registry.test.ts diff --git a/.changeset/steady-doctors-check.md b/.changeset/steady-doctors-check.md new file mode 100644 index 00000000..f01fe5ef --- /dev/null +++ b/.changeset/steady-doctors-check.md @@ -0,0 +1,5 @@ +--- +'@agentskit/cli': patch +--- + +Drive `agentskit doctor` from a static provider registry with explicit credential, reachability, and default-model status for high-demand providers, while keeping unsupported providers and secret values clearly out of network checks and output. diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index fb4acb15..a4aa35b8 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,5 +1,6 @@ import type { Command } from 'commander' import { runDoctor, renderReport } from '../doctor' +import { DEFAULT_DOCTOR_PROVIDERS } from '../provider-registry' export function registerDoctorCommand(program: Command): void { program @@ -8,7 +9,7 @@ export function registerDoctorCommand(program: Command): void { .option('--no-network', 'Skip provider reachability checks') .option( '--providers ', - 'Comma-separated providers to check (default: openai,anthropic,gemini,ollama)', + `Comma-separated providers to check (default: ${DEFAULT_DOCTOR_PROVIDERS.join(',')})`, ) .option('--json', 'Emit JSON instead of formatted output') .action(async (options) => { diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 70477a58..2f169e31 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { existsSync } from 'node:fs' import { loadConfig } from './config' +import { DEFAULT_DOCTOR_PROVIDERS, resolveProviderRegistryEntry } from './provider-registry' export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip' @@ -20,22 +21,6 @@ export interface DoctorReport { skip: number } -const PROVIDER_ENV_KEYS: Record = { - openai: 'OPENAI_API_KEY', - anthropic: 'ANTHROPIC_API_KEY', - gemini: 'GEMINI_API_KEY', - deepseek: 'DEEPSEEK_API_KEY', - grok: 'XAI_API_KEY', - kimi: 'KIMI_API_KEY', -} - -const PROVIDER_REACH_URLS: Record = { - openai: 'https://api.openai.com/v1/models', - anthropic: 'https://api.anthropic.com/v1/messages', - gemini: 'https://generativelanguage.googleapis.com/v1beta/models', - ollama: 'http://localhost:11434/api/tags', -} - // ============================================================================ // Individual checks // ============================================================================ @@ -122,17 +107,22 @@ export async function checkPackageJson(): Promise { } export async function checkProviderEnv(provider: string): Promise { - const envKey = PROVIDER_ENV_KEYS[provider] - if (!envKey) { - return { status: 'skip', name: `${provider} API key`, detail: 'No env-key requirement for this provider' } + const entry = resolveProviderRegistryEntry(provider) + if (entry.env.status === 'unsupported') { + return { status: 'skip', name: `${provider} API key`, detail: entry.env.reason } + } + if (entry.env.status === 'not-required') { + return { status: 'skip', name: `${provider} API key`, detail: entry.env.reason } } - const value = process.env[envKey] + const envKey = entry.env.keys.find(key => process.env[key]) + const expectedKeys = entry.env.keys.join(' or ') + const value = envKey ? process.env[envKey] : undefined if (!value) { return { status: 'skip', name: `${provider} API key`, - detail: `${envKey} not set`, - fix: `export ${envKey}=... (only needed if you use ${provider})`, + detail: `${expectedKeys} not set`, + fix: `export ${entry.env.keys[0]}=... (only needed if you use ${provider})`, } } if (value.length < 16) { @@ -151,14 +141,14 @@ export async function checkProviderReachable( fetchImpl: typeof fetch = fetch, timeoutMs = 4000, ): Promise { - const url = PROVIDER_REACH_URLS[provider] - if (!url) { - return { status: 'skip', name: `${provider} reachable`, detail: 'No reachability check configured' } + const entry = resolveProviderRegistryEntry(provider) + if (entry.reachability.status === 'unsupported') { + return { status: 'skip', name: `${provider} reachable`, detail: entry.reachability.reason } } + const url = entry.reachability.url // Skip reachability if no API key is set for keyed providers - const envKey = PROVIDER_ENV_KEYS[provider] - if (envKey && !process.env[envKey]) { + if (entry.env.status === 'required' && !entry.env.keys.some(key => process.env[key])) { return { status: 'skip', name: `${provider} reachable`, detail: 'Skipped — no API key configured' } } @@ -190,8 +180,10 @@ export async function checkProviderReachable( name: `${provider} reachable`, detail: `${url} → ${reason}`, fix: - provider === 'ollama' + entry.id === 'ollama' ? 'Start Ollama: `ollama serve` (or install from https://ollama.com)' + : entry.runtime === 'local' + ? `Start ${entry.label} server and verify its local endpoint` : 'Check network / firewall / VPN settings', } } finally { @@ -199,6 +191,18 @@ export async function checkProviderReachable( } } +export async function checkProviderDefaultModel(provider: string): Promise { + const entry = resolveProviderRegistryEntry(provider) + if (entry.defaultModel.status === 'unsupported') { + return { status: 'skip', name: `${provider} default model`, detail: entry.defaultModel.reason } + } + return { + status: 'pass', + name: `${provider} default model`, + detail: entry.defaultModel.id, + } +} + export async function checkConfig(): Promise { try { const config = await loadConfig() @@ -233,7 +237,7 @@ export interface DoctorOptions { } export async function runDoctor(options: DoctorOptions = {}): Promise { - const providers = options.providers ?? ['openai', 'anthropic', 'gemini', 'ollama'] + const providers = options.providers ?? DEFAULT_DOCTOR_PROVIDERS const fetchImpl = options.fetchImpl ?? fetch const checks: Array> = [ @@ -245,6 +249,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise 'doctorDefault' in entry && entry.doctorDefault === true) + .map(entry => entry.id) + +export function getProviderRegistryEntry(provider: string): ProviderRegistryEntry | undefined { + return PROVIDER_REGISTRY.find(entry => entry.id === provider.toLowerCase()) +} + +/** Return an explicit unsupported profile instead of silently omitting a provider. */ +export function resolveProviderRegistryEntry(provider: string): ProviderRegistryEntry { + return getProviderRegistryEntry(provider) ?? { + id: provider, + label: provider, + runtime: 'adapter-only', + env: { status: 'unsupported', reason: 'Provider is not in the CLI doctor registry.' }, + reachability: { status: 'unsupported', reason: 'Provider is not in the CLI doctor registry.' }, + defaultModel: { status: 'unsupported', reason: 'Provider is not in the CLI doctor registry.' }, + } +} diff --git a/packages/cli/tests/doctor.test.ts b/packages/cli/tests/doctor.test.ts index 8172a343..0ff8b383 100644 --- a/packages/cli/tests/doctor.test.ts +++ b/packages/cli/tests/doctor.test.ts @@ -3,6 +3,7 @@ import { checkNodeVersion, checkProviderEnv, checkProviderReachable, + checkProviderDefaultModel, runDoctor, renderReport, } from '../src/doctor' @@ -46,6 +47,21 @@ describe('checkProviderEnv', () => { const result = await checkProviderEnv('ollama') expect(result.status).toBe('skip') }) + + it('covers registry providers without exposing the key value', async () => { + const secret = 'groq-secret-value-that-must-not-escape' + process.env.GROQ_API_KEY = secret + const result = await checkProviderEnv('groq') + expect(result.status).toBe('pass') + expect(result.detail).toContain('GROQ_API_KEY set') + expect(result.detail).not.toContain(secret) + }) + + it('marks unknown providers as explicitly unsupported', async () => { + const result = await checkProviderEnv('unknown-provider') + expect(result.status).toBe('skip') + expect(result.detail).toContain('not in the CLI doctor registry') + }) }) describe('checkProviderReachable', () => { @@ -81,6 +97,14 @@ describe('checkProviderReachable', () => { expect(fakeFetch).not.toHaveBeenCalled() }) + it('skips unsupported reachability without touching the network', async () => { + const fakeFetch = vi.fn() + const result = await checkProviderReachable('unknown-provider', fakeFetch as unknown as typeof fetch) + expect(result.status).toBe('skip') + expect(result.detail).toContain('not in the CLI doctor registry') + expect(fakeFetch).not.toHaveBeenCalled() + }) + it('treats an abort as a fail with timeout context', async () => { const fakeFetch = vi.fn().mockImplementation(() => { const err = new Error('aborted') @@ -115,6 +139,29 @@ describe('runDoctor', () => { // No reachability rows when noNetwork expect(report.results.find(r => r.name === 'openai reachable')).toBeUndefined() }) + + it('reports a known default model while leaving network opt-in', async () => { + const fakeFetch = vi.fn() + const report = await runDoctor({ + providers: ['groq'], + noNetwork: true, + fetchImpl: fakeFetch as unknown as typeof fetch, + }) + expect(report.results.find(r => r.name === 'groq default model')).toMatchObject({ + status: 'pass', + detail: 'openai/gpt-oss-120b', + }) + expect(report.results.find(r => r.name === 'groq reachable')).toBeUndefined() + expect(fakeFetch).not.toHaveBeenCalled() + }) +}) + +describe('checkProviderDefaultModel', () => { + it('returns an explicit unsupported result for an unknown provider', async () => { + const result = await checkProviderDefaultModel('unknown-provider') + expect(result.status).toBe('skip') + expect(result.detail).toContain('not in the CLI doctor registry') + }) }) describe('renderReport', () => { diff --git a/packages/cli/tests/provider-registry.test.ts b/packages/cli/tests/provider-registry.test.ts new file mode 100644 index 00000000..446a34c8 --- /dev/null +++ b/packages/cli/tests/provider-registry.test.ts @@ -0,0 +1,46 @@ +import { catalog } from '@agentskit/adapters/catalog' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_DOCTOR_PROVIDERS, + PROVIDER_REGISTRY, + resolveProviderRegistryEntry, +} from '../src/provider-registry' + +describe('provider registry', () => { + it('has unique ids and explicit doctor surfaces', () => { + const ids = PROVIDER_REGISTRY.map(entry => entry.id) + + expect(new Set(ids).size).toBe(ids.length) + expect(PROVIDER_REGISTRY.every(entry => + entry.env.status && entry.reachability.status && entry.defaultModel.status, + )).toBe(true) + expect(DEFAULT_DOCTOR_PROVIDERS).toEqual(['openai', 'anthropic', 'gemini', 'ollama']) + }) + + it('keeps catalog-backed providers and default models aligned', () => { + for (const entry of PROVIDER_REGISTRY) { + if (!entry.catalogId) continue + const provider = catalog.providers.find(candidate => candidate.id === entry.catalogId) + expect(provider, `${entry.id} → ${entry.catalogId}`).toBeDefined() + if (entry.defaultModel.status === 'known') { + expect( + provider?.models.some(model => model.id === entry.defaultModel.id), + `${entry.id} default model ${entry.defaultModel.id}`, + ).toBe(true) + } + } + }) + + it('makes catalog providers outside the curated registry explicitly unsupported', () => { + const registeredCatalogIds = new Set( + PROVIDER_REGISTRY.flatMap(entry => entry.catalogId ? [entry.catalogId] : []), + ) + const unregistered = catalog.providers.find(provider => !registeredCatalogIds.has(provider.id)) + expect(unregistered).toBeDefined() + + const fallback = resolveProviderRegistryEntry(unregistered!.id) + expect(fallback.env.status).toBe('unsupported') + expect(fallback.reachability.status).toBe('unsupported') + expect(fallback.defaultModel.status).toBe('unsupported') + }) +}) From bdba82965b7ca0c98fefb7d4489c3c3a3e4c6088 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 20:07:10 -0300 Subject: [PATCH 06/13] feat(cli): add groq and openrouter providers --- .changeset/bright-cli-providers.md | 5 +++++ packages/cli/README.md | 7 ++++++- packages/cli/src/commands/add.ts | 2 +- packages/cli/src/provider-registry.ts | 4 ++-- packages/cli/src/providers.ts | 14 ++++++++++++++ packages/cli/src/slash-commands.ts | 2 +- packages/cli/tests/providers.test.ts | 22 ++++++++++++++++++++++ 7 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 .changeset/bright-cli-providers.md diff --git a/.changeset/bright-cli-providers.md b/.changeset/bright-cli-providers.md new file mode 100644 index 00000000..839a03a7 --- /dev/null +++ b/.changeset/bright-cli-providers.md @@ -0,0 +1,5 @@ +--- +'@agentskit/cli': patch +--- + +Allow `agentskit run` and `agentskit chat` to select Groq and OpenRouter using their environment keys and current catalog-backed default models. diff --git a/packages/cli/README.md b/packages/cli/README.md index 6393c18f..b032d3ad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,7 +39,7 @@ Docs: [package guide](https://www.agentskit.io/docs/packages/cli) · [agent hand - **Zero setup for prototyping** — go from idea to running conversation in under a minute; no boilerplate, no config files to write - **Scaffold production-ready projects** — generate a React chat app or terminal agent with the right structure so you skip the boring setup - **Script and automate** — pipe inputs, use env vars for keys, and compose with other Unix tools for lightweight agent scripting -- **Every provider, one command** — switch between OpenAI, Anthropic, Ollama (local), and more with a single flag +- **Every provider, one command** — switch between OpenAI, Anthropic, Groq, OpenRouter, Ollama (local), and more with a single flag ## Install @@ -57,6 +57,10 @@ ANTHROPIC_API_KEY=... agentskit chat --provider anthropic --model claude-sonnet- # Chat with a local model (no API key needed) agentskit chat --provider ollama --model llama3.1 +# Chat through Groq or OpenRouter +GROQ_API_KEY=... agentskit chat --provider groq +OPENROUTER_API_KEY=... agentskit chat --provider openrouter + # Scaffold a new React chat app agentskit init --template react --dir my-app @@ -84,6 +88,7 @@ agentskit run --help - **Extensibility** — plugins (slash commands, tools, skills, providers, hooks, MCP servers), permission policy with modes, lifecycle hooks (shell or JS), MCP stdio bridge, session rename + fork, `/usage` + `/cost` - Provider flags: `--provider`, `--model`, `--system`, `--skill`, `--memory`, `--mode`, `--plugin-dir` - Env-var based key injection — works seamlessly in CI and scripts +- Built-in live providers include OpenAI, Anthropic, Gemini, DeepSeek, Grok, Kimi, Groq, and OpenRouter; Ollama remains local and keyless Full reference: [docs/infrastructure/cli](https://www.agentskit.io/docs/infrastructure/cli) · [ARCHITECTURE.md](./ARCHITECTURE.md) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index bf1b8cd1..4d9db747 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -13,7 +13,7 @@ export function registerAddCommand(program: Command): void { .option('-f, --force', 'Overwrite existing files') .option('--registry ', 'Registry base URL override (component install)') .option('--run ', 'Run the agent on this task right after adding it') - .option('--provider ', 'Provider for --run (openai, anthropic, gemini, ollama, demo)', 'demo') + .option('--provider ', 'Provider for --run (openai, anthropic, gemini, ollama, groq, openrouter, demo)', 'demo') .option('--model ', 'Model for --run') .option('--api-key ', 'API key for --run (else read from the provider env var)') .action( diff --git a/packages/cli/src/provider-registry.ts b/packages/cli/src/provider-registry.ts index d5290db3..a3dd5938 100644 --- a/packages/cli/src/provider-registry.ts +++ b/packages/cli/src/provider-registry.ts @@ -108,7 +108,7 @@ export const PROVIDER_REGISTRY = [ { id: 'groq', label: 'Groq', - runtime: 'adapter-only', + runtime: 'cli', catalogId: 'groq', env: { status: 'required', keys: ['GROQ_API_KEY'] }, reachability: { status: 'supported', url: 'https://api.groq.com/openai/v1/models' }, @@ -117,7 +117,7 @@ export const PROVIDER_REGISTRY = [ { id: 'openrouter', label: 'OpenRouter', - runtime: 'adapter-only', + runtime: 'cli', catalogId: 'openrouter', env: { status: 'required', keys: ['OPENROUTER_API_KEY'] }, reachability: { status: 'supported', url: 'https://openrouter.ai/api/v1/models' }, diff --git a/packages/cli/src/providers.ts b/packages/cli/src/providers.ts index 43ca1e08..9a5e083a 100644 --- a/packages/cli/src/providers.ts +++ b/packages/cli/src/providers.ts @@ -2,10 +2,12 @@ import { anthropic, deepseek, gemini, + groq, grok, kimi, ollama, openai, + openrouter, } from '@agentskit/adapters' import type { AdapterFactory } from '@agentskit/core' @@ -75,6 +77,18 @@ const providers: Record = { requiresModel: true, factory: (c) => kimi(c), }, + groq: { + label: 'Groq', + envKeys: ['GROQ_API_KEY'], + defaultModel: 'openai/gpt-oss-120b', + factory: (c) => groq(c), + }, + openrouter: { + label: 'OpenRouter', + envKeys: ['OPENROUTER_API_KEY'], + defaultModel: '~anthropic/claude-haiku-latest', + factory: (c) => openrouter(c), + }, } function createDemoAdapter(provider: string, model?: string): AdapterFactory { diff --git a/packages/cli/src/slash-commands.ts b/packages/cli/src/slash-commands.ts index 955a959b..458ae7c6 100644 --- a/packages/cli/src/slash-commands.ts +++ b/packages/cli/src/slash-commands.ts @@ -99,7 +99,7 @@ export const builtinSlashCommands: SlashCommand[] = [ { name: 'provider', description: 'Switch the adapter provider.', - usage: '/provider openai|anthropic|gemini|ollama|deepseek|grok|kimi|demo', + usage: '/provider openai|anthropic|gemini|ollama|deepseek|grok|kimi|groq|openrouter|demo', run(ctx, args) { const value = args.trim() if (!value) { diff --git a/packages/cli/tests/providers.test.ts b/packages/cli/tests/providers.test.ts index 07dd1bea..eeb17c5f 100644 --- a/packages/cli/tests/providers.test.ts +++ b/packages/cli/tests/providers.test.ts @@ -12,6 +12,8 @@ beforeEach(() => { delete process.env.XAI_API_KEY delete process.env.KIMI_API_KEY delete process.env.MOONSHOT_API_KEY + delete process.env.GROQ_API_KEY + delete process.env.OPENROUTER_API_KEY }) afterEach(() => { @@ -88,6 +90,26 @@ describe('resolveChatProvider', () => { expect(r.model).toBe('llama3.1') }) + it('resolves Groq with its current default model', () => { + process.env.GROQ_API_KEY = 'gsk-test-key' + const r = resolveChatProvider({ provider: 'groq' }) + expect(r.provider).toBe('groq') + expect(r.model).toBe('openai/gpt-oss-120b') + expect(r.mode).toBe('live') + }) + + it('resolves OpenRouter with a fully-qualified default model', () => { + process.env.OPENROUTER_API_KEY = 'sk-or-test-key' + const r = resolveChatProvider({ provider: 'openrouter' }) + expect(r.provider).toBe('openrouter') + expect(r.model).toBe('~anthropic/claude-haiku-latest') + expect(r.mode).toBe('live') + }) + + it('requires a key for OpenRouter', () => { + expect(() => resolveChatProvider({ provider: 'openrouter' })).toThrow(/OPENROUTER_API_KEY/) + }) + it('case-insensitive provider name', () => { process.env.ANTHROPIC_API_KEY = 'sk-ant' const r = resolveChatProvider({ provider: 'ANTHROPIC' }) From 39dadc81f7c9b07b5d53802f53be57e4beb54df7 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Wed, 12 Aug 2026 20:09:25 -0300 Subject: [PATCH 07/13] feat(cli): scaffold groq and openrouter starters --- .changeset/quiet-scaffolds-connect.md | 5 +++++ packages/cli/README.md | 1 + packages/cli/src/commands/init.ts | 2 +- packages/cli/src/init-interactive.ts | 2 ++ packages/cli/src/init.ts | 8 +++++++- packages/cli/tests/init.test.ts | 24 ++++++++++++++++++++++++ 6 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-scaffolds-connect.md diff --git a/.changeset/quiet-scaffolds-connect.md b/.changeset/quiet-scaffolds-connect.md new file mode 100644 index 00000000..9a7c0d28 --- /dev/null +++ b/.changeset/quiet-scaffolds-connect.md @@ -0,0 +1,5 @@ +--- +'@agentskit/cli': patch +--- + +Allow `agentskit init` to scaffold Groq and OpenRouter starters with the correct adapter imports, current default models, and environment-file hints. diff --git a/packages/cli/README.md b/packages/cli/README.md index b032d3ad..1b9a58c8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -89,6 +89,7 @@ agentskit run --help - Provider flags: `--provider`, `--model`, `--system`, `--skill`, `--memory`, `--mode`, `--plugin-dir` - Env-var based key injection — works seamlessly in CI and scripts - Built-in live providers include OpenAI, Anthropic, Gemini, DeepSeek, Grok, Kimi, Groq, and OpenRouter; Ollama remains local and keyless +- `agentskit init --provider groq|openrouter` — scaffold a starter already wired to either provider Full reference: [docs/infrastructure/cli](https://www.agentskit.io/docs/infrastructure/cli) · [ARCHITECTURE.md](./ARCHITECTURE.md) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 69fd6210..17c5f661 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -10,7 +10,7 @@ export function registerInitCommand(program: Command): void { .description('Generate a starter project. Run with no flags for interactive mode.') .option('--template