diff --git a/middleware/eslint.config.mjs b/middleware/eslint.config.mjs index 6b24ec34..034b6bbf 100644 --- a/middleware/eslint.config.mjs +++ b/middleware/eslint.config.mjs @@ -18,10 +18,11 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], '@typescript-eslint/consistent-type-imports': 'error', - // LLM-provider decoupling (docs/plans/llm-provider-interface-plan.md): - // no middleware code may import the Anthropic SDK directly — go through - // the neutral @omadia/llm-provider contract. The Anthropic adapter (the - // ONLY sanctioned SDK consumer) lives in packages/llm-provider, which is + // LLM-provider decoupling (issue #298, docs/plans/issue-298-provider-plugins.md): + // no middleware code — INCLUDING the @omadia/llm-provider runtime core — may + // import a vendor SDK directly. Go through the neutral @omadia/llm-provider + // contract. The wire-format adapters (@omadia/llm-adapter-anthropic and + // @omadia/llm-adapter-openai) are the ONLY sanctioned SDK consumers and are // exempted in the override below. '@typescript-eslint/no-restricted-imports': [ 'error', @@ -30,7 +31,12 @@ export default tseslint.config( { group: ['@anthropic-ai/sdk', '@anthropic-ai/sdk/*'], message: - 'Import the neutral @omadia/llm-provider contract instead (LlmProvider, createAnthropicProvider, AnthropicClient, …). The Anthropic SDK is confined to packages/llm-provider. See docs/plans/llm-provider-interface-plan.md.', + 'Import the neutral @omadia/llm-provider contract instead (LlmProvider, …). The Anthropic SDK is confined to @omadia/llm-adapter-anthropic. See docs/plans/issue-298-provider-plugins.md.', + }, + { + group: ['openai', 'openai/*'], + message: + 'Import the neutral @omadia/llm-provider contract instead (LlmProvider, …). The OpenAI SDK is confined to @omadia/llm-adapter-openai. See docs/plans/issue-298-provider-plugins.md.', }, ], }, @@ -38,8 +44,11 @@ export default tseslint.config( }, }, { - // The Anthropic reference adapter is the one place the SDK is allowed. - files: ['packages/llm-provider/**/*.ts'], + // The wire-format adapter packages are the only places a vendor SDK is allowed. + files: [ + 'packages/llm-adapter-anthropic/**/*.ts', + 'packages/llm-adapter-openai/**/*.ts', + ], rules: { '@typescript-eslint/no-restricted-imports': 'off', }, diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 1ba34646..b7f2db6a 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -1964,10 +1964,22 @@ "resolved": "packages/harness-knowledge-graph-neon", "link": true }, + "node_modules/@omadia/llm-adapter-anthropic": { + "resolved": "packages/llm-adapter-anthropic", + "link": true + }, + "node_modules/@omadia/llm-adapter-openai": { + "resolved": "packages/llm-adapter-openai", + "link": true + }, "node_modules/@omadia/llm-provider": { "resolved": "packages/llm-provider", "link": true }, + "node_modules/@omadia/llm-provider-api": { + "resolved": "packages/llm-provider-api", + "link": true + }, "node_modules/@omadia/memory": { "resolved": "packages/harness-memory", "link": true @@ -8822,18 +8834,53 @@ "zod": "^4.0.0" } }, - "packages/llm-provider": { - "name": "@omadia/llm-provider", + "packages/llm-adapter-anthropic": { + "name": "@omadia/llm-adapter-anthropic", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@anthropic-ai/sdk": "*" + } + }, + "packages/llm-adapter-openai": { + "name": "@omadia/llm-adapter-openai", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@omadia/llm-provider-api": "*" + }, "engines": { "node": ">=20" }, "peerDependencies": { - "@anthropic-ai/sdk": "*", "openai": "*" } }, + "packages/llm-provider": { + "name": "@omadia/llm-provider", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "engines": { + "node": ">=20" + } + }, + "packages/llm-provider-api": { + "name": "@omadia/llm-provider-api", + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "packages/omadia-ui-channel": { "name": "@omadia/ui-channel", "version": "0.1.0", diff --git a/middleware/package.json b/middleware/package.json index d96e970d..092b52ff 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -9,14 +9,14 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", + "build": "npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", "start": "node dist/index.js", - "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", + "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", "dev:clean": "node scripts/dev-clean.mjs && npm run dev", "ensure-native-abi": "node scripts/ensure-native-abi.mjs", - "lint": "eslint src/ packages/plugin-api/src/ packages/llm-provider/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/", - "lint:fix": "eslint src/ packages/plugin-api/src/ packages/llm-provider/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/ --fix", - "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit", + "lint": "eslint src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/", + "lint:fix": "eslint src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-plugin-plan-runner/src/ --fix", + "typecheck": "npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/llm-adapter-anthropic && npm run typecheck -w @omadia/llm-adapter-openai && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "smoke:entity-refs": "tsx scripts/smoke-entity-refs.ts", diff --git a/middleware/packages/llm-adapter-anthropic/package.json b/middleware/packages/llm-adapter-anthropic/package.json new file mode 100644 index 00000000..315fbba6 --- /dev/null +++ b/middleware/packages/llm-adapter-anthropic/package.json @@ -0,0 +1,23 @@ +{ + "name": "@omadia/llm-adapter-anthropic", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "Anthropic Messages wire-format adapter for the omadia LLM provider seam. Implements the @omadia/llm-provider-api LlmAdapter contract (wireFormat 'anthropic') and confines all @anthropic-ai/sdk knowledge here. Register it into an LlmAdapterRegistry at boot via registerAnthropicAdapter. See docs/plans/issue-298-provider-plugins.md.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "peerDependencies": { + "@anthropic-ai/sdk": "*" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/llm-adapter-anthropic/src/adapter.ts b/middleware/packages/llm-adapter-anthropic/src/adapter.ts new file mode 100644 index 00000000..7020db96 --- /dev/null +++ b/middleware/packages/llm-adapter-anthropic/src/adapter.ts @@ -0,0 +1,36 @@ +/** + * Anthropic wire-format adapter registration. + * + * Wraps `createAnthropicProvider` (+ its SDK client) in the neutral `LlmAdapter` + * contract so the resolution seam in `@omadia/llm-provider` can build an + * Anthropic provider from resolved credentials without importing the SDK. Quirks + * are OpenAI-only and ignored here. + */ +import type { + LlmAdapter, + LlmAdapterBuildOptions, + LlmAdapterRegistry, + LlmProvider, +} from '@omadia/llm-provider-api'; + +import { createAnthropicClient } from './anthropicClient.js'; +import { createAnthropicProvider } from './anthropicProvider.js'; + +export const anthropicAdapter: LlmAdapter = { + wireFormat: 'anthropic', + build(opts: LlmAdapterBuildOptions): LlmProvider { + return createAnthropicProvider({ + client: createAnthropicClient({ + apiKey: opts.apiKey, + ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), + ...(opts.baseURL !== undefined ? { baseURL: opts.baseURL } : {}), + }), + ...(opts.log !== undefined ? { log: opts.log } : {}), + }); + }, +}; + +/** Register the Anthropic adapter into a registry (call once at boot). */ +export function registerAnthropicAdapter(registry: LlmAdapterRegistry): void { + registry.register(anthropicAdapter); +} diff --git a/middleware/packages/llm-provider/src/anthropicClient.ts b/middleware/packages/llm-adapter-anthropic/src/anthropicClient.ts similarity index 100% rename from middleware/packages/llm-provider/src/anthropicClient.ts rename to middleware/packages/llm-adapter-anthropic/src/anthropicClient.ts diff --git a/middleware/packages/llm-provider/src/anthropicProvider.ts b/middleware/packages/llm-adapter-anthropic/src/anthropicProvider.ts similarity index 99% rename from middleware/packages/llm-provider/src/anthropicProvider.ts rename to middleware/packages/llm-adapter-anthropic/src/anthropicProvider.ts index 6a0579b6..4c85e866 100644 --- a/middleware/packages/llm-provider/src/anthropicProvider.ts +++ b/middleware/packages/llm-adapter-anthropic/src/anthropicProvider.ts @@ -23,7 +23,7 @@ import type { TextPart, ToolChoice, ToolSpec, -} from './types.js'; +} from '@omadia/llm-provider-api'; export interface AnthropicProviderOptions { readonly client: Anthropic; diff --git a/middleware/packages/llm-adapter-anthropic/src/index.ts b/middleware/packages/llm-adapter-anthropic/src/index.ts new file mode 100644 index 00000000..3a48e525 --- /dev/null +++ b/middleware/packages/llm-adapter-anthropic/src/index.ts @@ -0,0 +1,21 @@ +/** + * `@omadia/llm-adapter-anthropic` — the Anthropic Messages wire-format adapter. + * + * Confines all `@anthropic-ai/sdk` knowledge to this package. The app registers + * it into the LLM adapter registry at boot (`registerAnthropicAdapter`); the + * builder/preview paths that construct a provider from a shared client import + * `createAnthropicClient` / `createAnthropicProvider` directly from here. + */ +export { anthropicAdapter, registerAnthropicAdapter } from './adapter.js'; + +export { + createAnthropicProvider, + classifyAnthropicError, + type AnthropicProviderOptions, +} from './anthropicProvider.js'; + +export { + createAnthropicClient, + type AnthropicClient, + type AnthropicClientOptions, +} from './anthropicClient.js'; diff --git a/middleware/packages/llm-adapter-anthropic/tsconfig.json b/middleware/packages/llm-adapter-anthropic/tsconfig.json new file mode 100644 index 00000000..4c3e1723 --- /dev/null +++ b/middleware/packages/llm-adapter-anthropic/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/middleware/packages/llm-adapter-openai/package.json b/middleware/packages/llm-adapter-openai/package.json new file mode 100644 index 00000000..068e4d84 --- /dev/null +++ b/middleware/packages/llm-adapter-openai/package.json @@ -0,0 +1,23 @@ +{ + "name": "@omadia/llm-adapter-openai", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "OpenAI Chat Completions wire-format adapter for the omadia LLM provider seam (covers the OpenAI-compatible family: Mistral/Ollama/vLLM/Azure/MiniMax via baseURL + quirks). Implements the @omadia/llm-provider-api LlmAdapter contract (wireFormat 'openai-compatible') and confines all openai SDK knowledge here. Register it into an LlmAdapterRegistry at boot via registerOpenAiAdapter. See docs/plans/issue-298-provider-plugins.md.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "peerDependencies": { + "openai": "*" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/llm-adapter-openai/src/adapter.ts b/middleware/packages/llm-adapter-openai/src/adapter.ts new file mode 100644 index 00000000..1cd027ec --- /dev/null +++ b/middleware/packages/llm-adapter-openai/src/adapter.ts @@ -0,0 +1,46 @@ +/** + * OpenAI (Chat Completions) wire-format adapter registration. + * + * Wraps `createOpenAiProvider` in the neutral `LlmAdapter` contract so the + * resolution seam in `@omadia/llm-provider` can build an OpenAI-compatible + * provider from resolved credentials + descriptor quirks without importing the + * SDK. This one adapter serves the whole OpenAI-compatible family — `id` + + * `baseURL` + `quirks` (from the descriptor) specialise it per provider. + */ +import type { + LlmAdapter, + LlmAdapterBuildOptions, + LlmAdapterRegistry, + LlmProvider, +} from '@omadia/llm-provider-api'; + +import { createOpenAiProvider } from './openaiProvider.js'; + +export const openAiAdapter: LlmAdapter = { + wireFormat: 'openai-compatible', + build(opts: LlmAdapterBuildOptions): LlmProvider { + const quirks = opts.quirks; + return createOpenAiProvider({ + apiKey: opts.apiKey, + ...(opts.baseURL !== undefined ? { baseURL: opts.baseURL } : {}), + ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), + ...(opts.id !== undefined ? { id: opts.id } : {}), + ...(quirks?.maxTokensField !== undefined + ? { maxTokensField: quirks.maxTokensField } + : {}), + ...(quirks?.dropToolChoice !== undefined + ? { dropToolChoice: quirks.dropToolChoice } + : {}), + ...(quirks?.checkBaseResp !== undefined + ? { checkBaseResp: quirks.checkBaseResp } + : {}), + ...(quirks?.extraBody !== undefined ? { extraBody: quirks.extraBody } : {}), + ...(opts.log !== undefined ? { log: opts.log } : {}), + }); + }, +}; + +/** Register the OpenAI-compatible adapter into a registry (call once at boot). */ +export function registerOpenAiAdapter(registry: LlmAdapterRegistry): void { + registry.register(openAiAdapter); +} diff --git a/middleware/packages/llm-adapter-openai/src/index.ts b/middleware/packages/llm-adapter-openai/src/index.ts new file mode 100644 index 00000000..c43953aa --- /dev/null +++ b/middleware/packages/llm-adapter-openai/src/index.ts @@ -0,0 +1,20 @@ +/** + * `@omadia/llm-adapter-openai` — the OpenAI Chat Completions wire-format adapter + * (also serves the OpenAI-compatible family: Mistral/Ollama/vLLM/Azure/MiniMax). + * + * Confines all `openai` SDK knowledge to this package. The app registers it into + * the LLM adapter registry at boot (`registerOpenAiAdapter`). + */ +export { openAiAdapter, registerOpenAiAdapter } from './adapter.js'; + +export { + createOpenAiProvider, + classifyOpenAiError, + type OpenAiProviderOptions, +} from './openaiProvider.js'; + +export { + createOpenAiClient, + type OpenAiClient, + type OpenAiClientOptions, +} from './openaiClient.js'; diff --git a/middleware/packages/llm-provider/src/openaiClient.ts b/middleware/packages/llm-adapter-openai/src/openaiClient.ts similarity index 100% rename from middleware/packages/llm-provider/src/openaiClient.ts rename to middleware/packages/llm-adapter-openai/src/openaiClient.ts diff --git a/middleware/packages/llm-provider/src/openaiProvider.ts b/middleware/packages/llm-adapter-openai/src/openaiProvider.ts similarity index 99% rename from middleware/packages/llm-provider/src/openaiProvider.ts rename to middleware/packages/llm-adapter-openai/src/openaiProvider.ts index 6e3c13ea..3da2a286 100644 --- a/middleware/packages/llm-provider/src/openaiProvider.ts +++ b/middleware/packages/llm-adapter-openai/src/openaiProvider.ts @@ -48,7 +48,7 @@ import type { ToolChoice, ToolResultPart, ToolSpec, -} from './types.js'; +} from '@omadia/llm-provider-api'; /** Provide EITHER a ready-made `client` OR an `apiKey` (+ optional `baseURL`) for * the adapter to build one via `createOpenAiClient`. At least one is required; diff --git a/middleware/packages/llm-adapter-openai/tsconfig.json b/middleware/packages/llm-adapter-openai/tsconfig.json new file mode 100644 index 00000000..4c3e1723 --- /dev/null +++ b/middleware/packages/llm-adapter-openai/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/middleware/packages/llm-provider-api/package.json b/middleware/packages/llm-provider-api/package.json new file mode 100644 index 00000000..9abbe0d1 --- /dev/null +++ b/middleware/packages/llm-provider-api/package.json @@ -0,0 +1,17 @@ +{ + "name": "@omadia/llm-provider-api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "The versioned, SDK-free LLM provider CONTRACT for the omadia middleware. Holds only the neutral DTOs (ChatMessage content parts, ToolSpec, LlmRequest/Response, usage), the LlmProvider interface, the model-registry types (ModelInfo/ModelClass/ModelRole), the provider descriptor (LlmProviderDescriptor/ProviderQuirks/ProviderPolicy/WireFormat), and the adapter contract (LlmAdapter) a wire-format adapter package implements. Zero runtime dependencies — this is the public surface a third-party provider plugin compiles against. See docs/plans/issue-298-provider-plugins.md.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/llm-provider-api/src/adapter.ts b/middleware/packages/llm-provider-api/src/adapter.ts new file mode 100644 index 00000000..7cf87390 --- /dev/null +++ b/middleware/packages/llm-provider-api/src/adapter.ts @@ -0,0 +1,52 @@ +/** + * Adapter CONTRACT — the seam that lets the concrete wire-format adapters live + * OUTSIDE this contract package and outside the runtime core. Each adapter + * package (`@omadia/llm-adapter-anthropic`, `@omadia/llm-adapter-openai`) + * implements `LlmAdapter` for one `WireFormat` and registers itself into an + * `LlmAdapterRegistry`. The resolution seam in `@omadia/llm-provider` looks up + * the adapter for a provider's wire format and calls `build()` — so neither the + * contract nor the runtime core imports a vendor SDK. + * + * Keying adapters by WIRE FORMAT (not provider id) is deliberate: a new provider + * that speaks an existing wire format needs only a descriptor (a plugin); a + * genuinely new protocol needs a new adapter package that registers itself here. + */ +import type { ProviderQuirks, WireFormat } from './descriptor.js'; +import type { LlmProvider } from './types.js'; + +/** Everything an adapter needs to construct a concrete provider from resolved + * credentials + descriptor metadata. The resolution seam fills this in. */ +export interface LlmAdapterBuildOptions { + /** Resolved API key (the seam already read it from the vault). */ + readonly apiKey: string; + /** API base URL. Omitted only for the literal `openai` provider (its SDK + * defaults to api.openai.com); every other id carries one. */ + readonly baseURL?: string; + /** SDK auto-retry count (the orchestrator uses 5; others keep the SDK default). */ + readonly maxRetries?: number; + /** Provider id to stamp on the built `LlmProvider` for non-default + * openai-compatible providers (e.g. `mistral`, `minimax`). */ + readonly id?: string; + /** OpenAI-adapter vendor quirks from the descriptor; ignored by other adapters. */ + readonly quirks?: ProviderQuirks; + readonly log?: (...args: unknown[]) => void; +} + +/** A wire-format adapter: turns resolved credentials into a working provider. */ +export interface LlmAdapter { + /** The wire format this adapter speaks — its registry key. */ + readonly wireFormat: WireFormat; + /** Build a concrete provider. Synchronous: SDK clients construct eagerly. */ + build(opts: LlmAdapterBuildOptions): LlmProvider; +} + +/** Registry of wire-format adapters. The runtime core ships a concrete + * implementation + a process-default instance; adapter packages register into + * it at boot, and the resolution seam reads from it. */ +export interface LlmAdapterRegistry { + /** Register (or idempotently replace) the adapter for its wire format. */ + register(adapter: LlmAdapter): void; + get(wireFormat: WireFormat): LlmAdapter | undefined; + has(wireFormat: WireFormat): boolean; + list(): ReadonlyArray; +} diff --git a/middleware/packages/llm-provider-api/src/descriptor.ts b/middleware/packages/llm-provider-api/src/descriptor.ts new file mode 100644 index 00000000..86b56eca --- /dev/null +++ b/middleware/packages/llm-provider-api/src/descriptor.ts @@ -0,0 +1,61 @@ +/** + * Provider DESCRIPTOR contract — what a provider plugin's `llm_provider` + * manifest block (or a bundled built-in) contributes to the runtime catalog. + * It is purely declarative data: which wire format to speak, where the API + * lives, vendor quirks for the OpenAI-compatible adapter, compliance hints for + * the operator UI, and the models served. The runtime catalog + the resolution + * seam that consume this live in `@omadia/llm-provider`. + */ +import type { ModelInfo } from './models.js'; + +/** The HTTP wire protocol an adapter speaks. A provider picks one; the matching + * registered `LlmAdapter` (see ./adapter.ts) builds the concrete provider. + * `openai-compatible` = OpenAI Chat Completions (most providers); `anthropic` = + * Anthropic Messages (Claude, or an Anthropic-compatible gateway). */ +export type WireFormat = 'openai-compatible' | 'anthropic'; + +/** Vendor deviations from plain OpenAI that the OpenAI adapter handles when set. */ +export interface ProviderQuirks { + /** Field carrying the output-token cap (MiniMax → `max_completion_tokens`). */ + readonly maxTokensField?: 'max_tokens' | 'max_completion_tokens'; + /** Omit `tool_choice` / `parallel_tool_calls` (MiniMax doesn't accept them). */ + readonly dropToolChoice?: boolean; + /** Throw on a non-zero in-body `base_resp.status_code` (MiniMax) even on 200. */ + readonly checkBaseResp?: boolean; + /** Vendor-only request fields merged into every body (MiniMax `reasoning_split`). */ + readonly extraBody?: Record; +} + +/** Provider data-protection hints for the operator UI. Defaults are the safe + * conservative choice: a provider with no policy is treated as a third-party, + * non-EU processor (disclosure shown, no EU-hosting note). */ +export interface ProviderPolicy { + /** Show the AVV / Art. 28 DSGVO third-party-processing disclosure before + * routing an agent to this provider. Default (omitted) = true. */ + readonly requiresAvvDisclosure?: boolean; + /** Provider is hosted in the EU (no third-country transfer) — surfaces a note. + * Default (omitted) = false. */ + readonly euHosted?: boolean; + /** Whether this provider needs an API key to be usable. Local / self-hosted + * providers (e.g. Ollama) run without credentials — set `false` so the + * factory builds the provider with an empty key instead of treating the + * missing key as "not connected". Default (omitted) = true. */ + readonly requiresApiKey?: boolean; +} + +/** A plugin-contributed (or bundled built-in) provider. `quirks` only apply to + * the openai-compatible adapter. */ +export interface LlmProviderDescriptor { + readonly id: string; + readonly label: string; + readonly wireFormat: WireFormat; + /** Default API base URL (e.g. `https://api.minimax.io/v1`). */ + readonly baseURL: string; + /** Optional config key an operator can set to override `baseURL` per scope. */ + readonly baseUrlConfigKey?: string; + readonly quirks?: ProviderQuirks; + /** Operator-UI compliance hints (not LLM behaviour) surfaced on the admin + * providers page so the view stays data-driven instead of hard-coding ids. */ + readonly policy?: ProviderPolicy; + readonly models: ReadonlyArray; +} diff --git a/middleware/packages/llm-provider-api/src/index.ts b/middleware/packages/llm-provider-api/src/index.ts new file mode 100644 index 00000000..8f197571 --- /dev/null +++ b/middleware/packages/llm-provider-api/src/index.ts @@ -0,0 +1,54 @@ +/** + * `@omadia/llm-provider-api` — the versioned, SDK-free LLM provider contract. + * + * This is the entire public surface a provider/adapter plugin compiles against: + * the neutral request/response DTOs, the `LlmProvider` interface, the model + * descriptor + registry types, and the wire-format adapter contract. It has zero + * runtime dependencies and never imports a vendor SDK. The runtime that consumes + * this contract (catalog, model registry, resolution, adapter registry) lives in + * `@omadia/llm-provider`; the concrete adapters live in `@omadia/llm-adapter-*`. + */ + +// Neutral LLM DTOs + content helpers. +export type { + CacheHints, + ChatMessage, + ContentPart, + FinishReason, + ImagePart, + LlmErrorClassification, + LlmErrorKind, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, + LlmUsage, + ProviderCapabilities, + SystemBlock, + TextPart, + ToolCallPart, + ToolChoice, + ToolResultPart, + ToolSpec, +} from './types.js'; +export { collectText, textMessage, toolCalls } from './types.js'; + +// Model-registry contract types (runtime registry lives in @omadia/llm-provider). +export type { ModelClass, ModelInfo, ModelRole, ProviderId } from './models.js'; + +// Provider descriptor contract (runtime catalog lives in @omadia/llm-provider). +export type { + LlmProviderDescriptor, + ProviderPolicy, + ProviderQuirks, + WireFormat, +} from './descriptor.js'; + +// Wire-format adapter contract (implemented by @omadia/llm-adapter-* packages). +export type { + LlmAdapter, + LlmAdapterBuildOptions, + LlmAdapterRegistry, +} from './adapter.js'; + +export { LLM_PROVIDER_API_VERSION } from './version.js'; diff --git a/middleware/packages/llm-provider-api/src/models.ts b/middleware/packages/llm-provider-api/src/models.ts new file mode 100644 index 00000000..8fb1acc5 --- /dev/null +++ b/middleware/packages/llm-provider-api/src/models.ts @@ -0,0 +1,49 @@ +/** + * Model-registry CONTRACT types (the data shape; the runtime registry that + * validates/indexes/resolves these lives in `@omadia/llm-provider`). + * + * A provider descriptor (manifest `llm_provider` block, or a bundled built-in) + * contributes a list of `ModelInfo`; the runtime overlay merges them. Keeping + * the type here lets a provider plugin declare its models against the versioned + * contract without importing the runtime registry. + */ + +/** Capability/quality tier. Maps a capability request to a concrete model per + * provider. Builder slugs `haiku|sonnet|opus` are legacy aliases onto these. */ +export type ModelClass = 'fast' | 'balanced' | 'frontier'; + +/** A functional role the host assigns a model to. Each role has a default + * class (see ROLE_DEFAULT_CLASS); the registry resolves role → class → model. */ +export type ModelRole = + | 'orchestrator' + | 'subagent' + | 'classifier' + | 'verifier' + | 'codegen' + | 'preview'; + +/** Provider id — matches the `LlmProvider.id` of the adapter that serves it. */ +export type ProviderId = 'anthropic' | 'openai' | 'openai-compatible' | string; + +export interface ModelInfo { + /** Provider-qualified id, the registry's primary key: `anthropic:claude-opus-4-8`. */ + readonly id: string; + readonly provider: ProviderId; + /** Bare vendor id the adapter receives: `claude-opus-4-8`, `gpt-4.1`. */ + readonly modelId: string; + readonly label: string; + readonly class: ModelClass; + /** Default max OUTPUT tokens (the model's capability ceiling; callers may + * request fewer). Distinct from a per-feature output budget. */ + readonly maxTokens: number; + /** Total context window (input + output) in tokens. */ + readonly contextWindow: number; + readonly vision: boolean; + /** Legacy/alternate references that resolve to this model (e.g. builder + * slugs `opus`/`sonnet`/`haiku`). Aliases must be globally unique. */ + readonly aliases?: ReadonlyArray; + /** Marks the canonical model for its `(provider, class)` pair. REQUIRED to be + * set on exactly one model when a provider has >1 model of a class, so + * `class:`/role resolution never depends on array order. */ + readonly classDefault?: boolean; +} diff --git a/middleware/packages/llm-provider/src/types.ts b/middleware/packages/llm-provider-api/src/types.ts similarity index 100% rename from middleware/packages/llm-provider/src/types.ts rename to middleware/packages/llm-provider-api/src/types.ts diff --git a/middleware/packages/llm-provider-api/src/version.ts b/middleware/packages/llm-provider-api/src/version.ts new file mode 100644 index 00000000..83f879af --- /dev/null +++ b/middleware/packages/llm-provider-api/src/version.ts @@ -0,0 +1,7 @@ +/** + * Contract version. A provider/adapter plugin compiled against this package + * declares which contract it implements; the host can compare and refuse a + * mismatch at install. Bump MAJOR on a breaking change to any exported type in + * this package, MINOR on a backward-compatible addition. + */ +export const LLM_PROVIDER_API_VERSION = '1.0.0' as const; diff --git a/middleware/packages/llm-provider-api/tsconfig.json b/middleware/packages/llm-provider-api/tsconfig.json new file mode 100644 index 00000000..4c3e1723 --- /dev/null +++ b/middleware/packages/llm-provider-api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/middleware/packages/llm-provider/package.json b/middleware/packages/llm-provider/package.json index 31922cde..377c2674 100644 --- a/middleware/packages/llm-provider/package.json +++ b/middleware/packages/llm-provider/package.json @@ -5,15 +5,14 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "description": "Provider-agnostic LLM contract for the omadia middleware. Defines the neutral request/response DTOs (ChatMessage content parts, ToolSpec, FinishReason, usage incl. cache tokens), the LlmProvider interface (complete/stream/classifyError + capabilities), and ships the Anthropic and OpenAI (incl. OpenAI-compatible: Mistral/Ollama/vLLM/Azure) reference adapters. Decoupling plan: docs/plans/llm-provider-interface-plan.md.", + "description": "SDK-free runtime for the omadia LLM provider seam. Re-exports the @omadia/llm-provider-api contract and adds the runtime: model registry, provider catalog, credential resolution, the wire-format adapter registry, and resolveLlmProvider (a pure registry lookup). Contains no concrete adapter and imports no vendor SDK - the Anthropic/OpenAI adapters live in @omadia/llm-adapter-*. See docs/plans/issue-298-provider-plugins.md.", "license": "MIT", "scripts": { "build": "tsc", "typecheck": "tsc --noEmit" }, - "peerDependencies": { - "@anthropic-ai/sdk": "*", - "openai": "*" + "dependencies": { + "@omadia/llm-provider-api": "*" }, "engines": { "node": ">=20" diff --git a/middleware/packages/llm-provider/src/adapterRegistry.ts b/middleware/packages/llm-provider/src/adapterRegistry.ts new file mode 100644 index 00000000..58c4f5a6 --- /dev/null +++ b/middleware/packages/llm-provider/src/adapterRegistry.ts @@ -0,0 +1,49 @@ +/** + * Concrete wire-format adapter registry (runtime). + * + * The contract (`LlmAdapter`, `LlmAdapterRegistry`) lives in + * `@omadia/llm-provider-api`; this is the runtime that holds the registered + * adapters and the resolution seam (`providerFactory`) reads from. The concrete + * adapters themselves live in `@omadia/llm-adapter-*` packages so this core + * package never imports a vendor SDK. + * + * A process-wide default instance (`defaultLlmAdapters`) is exported so the app + * can register its bundled adapters once at boot (`registerAnthropicAdapter`, + * `registerOpenAiAdapter`) and every `resolveLlmProvider` call sees them without + * threading the registry through each consumer. Tests can build an isolated + * `LlmAdapterRegistryImpl` and pass it explicitly to `resolveLlmProvider`. + */ +import type { + LlmAdapter, + LlmAdapterRegistry, + WireFormat, +} from '@omadia/llm-provider-api'; + +export class LlmAdapterRegistryImpl implements LlmAdapterRegistry { + private readonly adapters = new Map(); + + register(adapter: LlmAdapter): void { + this.adapters.set(adapter.wireFormat, adapter); + } + + get(wireFormat: WireFormat): LlmAdapter | undefined { + return this.adapters.get(wireFormat); + } + + has(wireFormat: WireFormat): boolean { + return this.adapters.has(wireFormat); + } + + list(): ReadonlyArray { + return [...this.adapters.values()]; + } + + /** Test/teardown helper — drop all registered adapters. */ + clear(): void { + this.adapters.clear(); + } +} + +/** Process-wide default registry. The app registers its bundled adapters into + * this at boot; `resolveLlmProvider` defaults to it. */ +export const defaultLlmAdapters = new LlmAdapterRegistryImpl(); diff --git a/middleware/packages/llm-provider/src/index.ts b/middleware/packages/llm-provider/src/index.ts index a12e0beb..31de5302 100644 --- a/middleware/packages/llm-provider/src/index.ts +++ b/middleware/packages/llm-provider/src/index.ts @@ -1,9 +1,25 @@ +/** + * `@omadia/llm-provider` — the SDK-free runtime for the LLM provider seam. + * + * The neutral CONTRACT (DTOs, `LlmProvider`, model + descriptor + adapter types) + * lives in `@omadia/llm-provider-api` and is re-exported here so existing + * consumers keep importing from `@omadia/llm-provider` unchanged. This package + * adds the RUNTIME: the model registry, the provider catalog, credential + * resolution, the wire-format adapter registry, and `resolveLlmProvider`. It + * imports NO vendor SDK and contains NO concrete adapter — those live in + * `@omadia/llm-adapter-anthropic` / `@omadia/llm-adapter-openai` (issue #298). + */ + +// ---- Contract (re-exported from the versioned contract package) ---- export type { CacheHints, ChatMessage, ContentPart, FinishReason, ImagePart, + LlmAdapter, + LlmAdapterBuildOptions, + LlmAdapterRegistry, LlmErrorClassification, LlmErrorKind, LlmProvider, @@ -18,53 +34,44 @@ export type { ToolChoice, ToolResultPart, ToolSpec, -} from './types.js'; - -export { collectText, textMessage, toolCalls } from './types.js'; - +} from '@omadia/llm-provider-api'; export { - classifyAnthropicError, - createAnthropicProvider, - type AnthropicProviderOptions, -} from './anthropicProvider.js'; - -export { - createAnthropicClient, - type AnthropicClient, - type AnthropicClientOptions, -} from './anthropicClient.js'; - -export { - classifyOpenAiError, - createOpenAiProvider, - type OpenAiProviderOptions, -} from './openaiProvider.js'; - -export { - createOpenAiClient, - type OpenAiClient, - type OpenAiClientOptions, -} from './openaiClient.js'; + collectText, + LLM_PROVIDER_API_VERSION, + textMessage, + toolCalls, +} from '@omadia/llm-provider-api'; +// ---- Runtime: credentials ---- export { legacyProviderApiKeyVaultKey, providerApiKeyVaultKey, readProviderApiKey, } from './providerCredentials.js'; +// ---- Runtime: provider resolution (registry lookup) ---- export { knownProviderBaseUrl, resolveLlmProvider, type ResolveLlmProviderOptions, } from './providerFactory.js'; +// ---- Runtime: wire-format adapter registry ---- +export { + defaultLlmAdapters, + LlmAdapterRegistryImpl, +} from './adapterRegistry.js'; + +// ---- Runtime: provider catalog (+ re-exported descriptor contract types) ---- export { LlmProviderCatalog, type LlmProviderDescriptor, type ProviderPolicy, type ProviderQuirks, + type WireFormat, } from './providerCatalog.js'; +// ---- Runtime: model registry (+ re-exported model contract types) ---- export { clearExternalModels, coerceModelToProvider, diff --git a/middleware/packages/llm-provider/src/modelRegistry.ts b/middleware/packages/llm-provider/src/modelRegistry.ts index c2c6b9af..646d7986 100644 Binary files a/middleware/packages/llm-provider/src/modelRegistry.ts and b/middleware/packages/llm-provider/src/modelRegistry.ts differ diff --git a/middleware/packages/llm-provider/src/providerCatalog.ts b/middleware/packages/llm-provider/src/providerCatalog.ts index 6d706148..2dc8cad3 100644 --- a/middleware/packages/llm-provider/src/providerCatalog.ts +++ b/middleware/packages/llm-provider/src/providerCatalog.ts @@ -9,59 +9,25 @@ * * Registering a provider also registers its models into the model-registry * overlay (so admin/class/role resolution sees them); unregistering cleans both - * up. Only the OpenAI-compatible wire format is supported today — that is what - * lets a declarative descriptor (no plugin code) drive the existing OpenAI - * adapter via a baseURL + quirk flags. + * up. A descriptor names a `wireFormat`; the matching adapter (registered into + * the `LlmAdapterRegistry` by a `@omadia/llm-adapter-*` package) builds the + * concrete provider — so a declarative descriptor (no plugin code) drives an + * existing adapter via baseURL + quirk flags. */ -import { registerExternalModels, type ModelInfo } from './modelRegistry.js'; +import type { LlmProviderDescriptor } from '@omadia/llm-provider-api'; -/** Vendor deviations from plain OpenAI that the OpenAI adapter handles when set. */ -export interface ProviderQuirks { - /** Field carrying the output-token cap (MiniMax → `max_completion_tokens`). */ - readonly maxTokensField?: 'max_tokens' | 'max_completion_tokens'; - /** Omit `tool_choice` / `parallel_tool_calls` (MiniMax doesn't accept them). */ - readonly dropToolChoice?: boolean; - /** Throw on a non-zero in-body `base_resp.status_code` (MiniMax) even on 200. */ - readonly checkBaseResp?: boolean; - /** Vendor-only request fields merged into every body (MiniMax `reasoning_split`). */ - readonly extraBody?: Record; -} - -/** A plugin-contributed provider. `openai-compatible` speaks the OpenAI Chat - * Completions wire format (most providers); `anthropic` speaks the Anthropic - * Messages wire format (Claude, or an Anthropic-compatible gateway). `quirks` - * only apply to the openai-compatible adapter. */ -export interface LlmProviderDescriptor { - readonly id: string; - readonly label: string; - readonly wireFormat: 'openai-compatible' | 'anthropic'; - /** Default API base URL (e.g. `https://api.minimax.io/v1`). */ - readonly baseURL: string; - /** Optional config key an operator can set to override `baseURL` per scope. */ - readonly baseUrlConfigKey?: string; - readonly quirks?: ProviderQuirks; - /** Operator-UI compliance hints (not LLM behaviour) surfaced on the admin - * providers page so the view stays data-driven instead of hard-coding ids. */ - readonly policy?: ProviderPolicy; - readonly models: ReadonlyArray; -} +import { registerExternalModels } from './modelRegistry.js'; -/** Provider data-protection hints for the operator UI. Defaults are the safe - * conservative choice: a provider with no policy is treated as a third-party, - * non-EU processor (disclosure shown, no EU-hosting note). */ -export interface ProviderPolicy { - /** Show the AVV / Art. 28 DSGVO third-party-processing disclosure before - * routing an agent to this provider. Default (omitted) = true. */ - readonly requiresAvvDisclosure?: boolean; - /** Provider is hosted in the EU (no third-country transfer) — surfaces a note. - * Default (omitted) = false. */ - readonly euHosted?: boolean; - /** Whether this provider needs an API key to be usable. Local / self-hosted - * providers (e.g. Ollama) run without credentials — set `false` so the - * factory builds the provider with an empty key instead of treating the - * missing key as "not connected". Default (omitted) = true. */ - readonly requiresApiKey?: boolean; -} +// The descriptor contract (LlmProviderDescriptor, ProviderQuirks, ProviderPolicy, +// WireFormat) now lives in the versioned contract package. Re-export it here so +// existing `@omadia/llm-provider` consumers are unaffected; the runtime catalog +// below is unchanged. +export type { + LlmProviderDescriptor, + ProviderPolicy, + ProviderQuirks, + WireFormat, +} from '@omadia/llm-provider-api'; export class LlmProviderCatalog { private readonly entries = new Map< diff --git a/middleware/packages/llm-provider/src/providerFactory.ts b/middleware/packages/llm-provider/src/providerFactory.ts index 80bf8e34..a852f92a 100644 --- a/middleware/packages/llm-provider/src/providerFactory.ts +++ b/middleware/packages/llm-provider/src/providerFactory.ts @@ -1,26 +1,33 @@ /** - * Provider factory (phase 4): build the right `LlmProvider` for a configured - * provider id from a vault scope's credentials. This is the seam that lets a - * plugin/kernel pick Anthropic or OpenAI (or an OpenAI-compatible server) by - * configuration instead of hard-coding `createAnthropicProvider`. + * Provider factory: build the right `LlmProvider` for a configured provider id + * from a vault scope's credentials. This is the seam that lets a plugin/kernel + * pick a provider by CONFIGURATION instead of hard-coding an adapter. + * + * Resolution is now a pure REGISTRY LOOKUP (issue #298): the factory resolves the + * provider's wire format (from a catalog descriptor, or the `anthropic` default) + * and the matching adapter from an `LlmAdapterRegistry`, then calls + * `adapter.build(...)`. The concrete adapters (and their SDKs) live in + * `@omadia/llm-adapter-*` packages and are registered into the default registry + * at boot — so THIS package imports no vendor SDK and contains no provider switch. * * It returns the RAW provider; callers keep their own concerns on top (e.g. * `withProviderUsageTracking`, or the orchestrator recording usage itself). It * returns `undefined` when no API key is configured for the provider, so the - * caller skips publishing its capability exactly as the Anthropic-only path - * did before. + * caller skips publishing its capability exactly as the Anthropic-only path did. * - * Zero-behavior-change for the Anthropic default: `providerId: 'anthropic'` - * with the same key + maxRetries produces the identical provider the call - * sites built inline previously. + * Zero-behavior-change for the Anthropic default: `providerId: 'anthropic'` with + * the same key + maxRetries produces the identical provider as before, provided + * the Anthropic adapter has been registered (the app does this at boot). */ -import { createAnthropicClient } from './anthropicClient.js'; -import { createAnthropicProvider } from './anthropicProvider.js'; +import type { + LlmAdapterRegistry, + LlmProvider, +} from '@omadia/llm-provider-api'; + +import { defaultLlmAdapters } from './adapterRegistry.js'; import type { ProviderId } from './modelRegistry.js'; -import { createOpenAiProvider } from './openaiProvider.js'; import type { LlmProviderCatalog } from './providerCatalog.js'; import { readProviderApiKey } from './providerCredentials.js'; -import type { LlmProvider } from './types.js'; /** * Default API base URLs for well-known OpenAI-compatible providers, so an @@ -51,9 +58,13 @@ export interface ResolveLlmProviderOptions { /** SDK auto-retry count (the orchestrator uses 5; others keep the SDK default). */ readonly maxRetries?: number; /** Plugin-contributed provider catalog. When `providerId` is found here, its - * `baseURL` + quirks drive the OpenAI-compatible adapter — this is how a + * `wireFormat` + `baseURL` + quirks drive resolution — this is how a * declarative provider plugin (e.g. MiniMax) becomes resolvable. */ readonly catalog?: LlmProviderCatalog; + /** Wire-format adapter registry. Defaults to the process-wide + * `defaultLlmAdapters` (the app registers its bundled adapters into it at + * boot); tests pass an isolated registry. */ + readonly adapters?: LlmAdapterRegistry; readonly log?: (...args: unknown[]) => void; } @@ -66,76 +77,66 @@ export interface ResolveLlmProviderOptions { export async function resolveLlmProvider( opts: ResolveLlmProviderOptions, ): Promise { - // Resolve the catalog descriptor first — it tells us whether this provider - // even needs a key. A plugin-contributed provider (the catalog) declares its - // wireFormat; the built-in `anthropic` default keeps the Anthropic wire format - // with no descriptor. + // Resolve the catalog descriptor first — it tells us whether this provider even + // needs a key, and which wire format to resolve. A plugin-contributed provider + // declares its wireFormat; the built-in `anthropic` default keeps the Anthropic + // wire format with no descriptor. const descriptor = opts.catalog?.get(opts.providerId); const apiKey = await readProviderApiKey(opts.getSecret, opts.providerId); // Local / self-hosted providers (e.g. Ollama) declare `policy.requiresApiKey: - // false` and run without credentials — build them with an empty key. For every - // other provider, a missing key means "not connected" → no provider. + // false` and run without credentials. For every other provider, a missing key + // means "not connected" → no provider (caller skips publishing its capability). const keyless = descriptor?.policy?.requiresApiKey === false; if (apiKey === undefined && !keyless) return undefined; - // The OpenAI/Anthropic SDK constructors reject a falsy apiKey, so a keyless - // provider (no credential by design — Ollama ignores the Authorization header) - // gets a non-empty placeholder instead of ''. Only reached when apiKey is - // genuinely absent AND the provider declared requiresApiKey:false. + // The SDK constructors reject a falsy apiKey, so a keyless provider (no + // credential by design — Ollama ignores the Authorization header) gets a + // non-empty placeholder instead of ''. Only reached when apiKey is genuinely + // absent AND the provider declared requiresApiKey:false. const resolvedKey = apiKey ?? 'no-key-required'; - // Resolve the wire format + baseURL once. baseURL precedence: explicit - // opts.baseURL (self-hosted gateway / Azure) > catalog descriptor > a well-known - // default (knownProviderBaseUrl, e.g. mistral) so the operator never types it. + // baseURL precedence: explicit opts.baseURL (self-hosted gateway / Azure) > + // catalog descriptor > a well-known default (knownProviderBaseUrl, e.g. mistral) + // so the operator never types it. const wireFormat = descriptor?.wireFormat ?? (opts.providerId === 'anthropic' ? 'anthropic' : 'openai-compatible'); const baseURL = opts.baseURL ?? descriptor?.baseURL ?? knownProviderBaseUrl(opts.providerId); - if (wireFormat === 'anthropic') { - // Anthropic Messages wire format (Claude, or an Anthropic-compatible - // gateway). No baseURL → SDK default (zero-behavior-change for the built-in - // anthropic default). Quirks are openai-only and do not apply here. - return createAnthropicProvider({ - client: createAnthropicClient({ - apiKey: resolvedKey, - ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), - ...(baseURL !== undefined ? { baseURL } : {}), - }), - ...(opts.log !== undefined ? { log: opts.log } : {}), - }); - } - - // openai, openai-compatible, and any custom compatible id all speak the - // OpenAI Chat Completions wire format via the same adapter. A plugin descriptor - // also carries the OpenAI-adapter quirks. - const quirks = descriptor?.quirks; - // Guard the footgun: only the literal 'openai' may omit a baseURL (it defaults - // to api.openai.com). Any other id without a (default or explicit) baseURL - // would silently send a non-OpenAI key to api.openai.com and fail opaquely at - // request time — fail loudly at build time instead. - if (opts.providerId !== 'openai' && baseURL === undefined) { + // to api.openai.com). Any other openai-compatible id without a (default or + // explicit) baseURL would silently send a non-OpenAI key to api.openai.com and + // fail opaquely at request time — fail loudly at build time instead. (The + // anthropic wire format defaults its own baseURL inside the SDK, so it is exempt.) + if ( + wireFormat === 'openai-compatible' && + opts.providerId !== 'openai' && + baseURL === undefined + ) { throw new Error( `LLM provider '${opts.providerId}' requires a baseURL (an OpenAI-compatible endpoint); only 'openai' may omit it.`, ); } - return createOpenAiProvider({ + + const registry = opts.adapters ?? defaultLlmAdapters; + const adapter = registry.get(wireFormat); + if (adapter === undefined) { + throw new Error( + `No LLM adapter registered for wire format '${wireFormat}' (provider '${opts.providerId}'). ` + + `Register an @omadia/llm-adapter-* package for it at boot (e.g. registerAnthropicAdapter / registerOpenAiAdapter).`, + ); + } + + // `id` stamps a non-default openai-compatible provider (mistral/minimax/…); + // 'openai' and the anthropic adapter use their own fixed id. `quirks` apply to + // the openai-compatible adapter only; other adapters ignore them. + return adapter.build({ apiKey: resolvedKey, ...(baseURL !== undefined ? { baseURL } : {}), ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), ...(opts.providerId !== 'openai' ? { id: opts.providerId } : {}), - ...(quirks?.maxTokensField !== undefined - ? { maxTokensField: quirks.maxTokensField } - : {}), - ...(quirks?.dropToolChoice !== undefined - ? { dropToolChoice: quirks.dropToolChoice } - : {}), - ...(quirks?.checkBaseResp !== undefined - ? { checkBaseResp: quirks.checkBaseResp } - : {}), - ...(quirks?.extraBody !== undefined ? { extraBody: quirks.extraBody } : {}), + ...(descriptor?.quirks !== undefined ? { quirks: descriptor.quirks } : {}), ...(opts.log !== undefined ? { log: opts.log } : {}), }); } diff --git a/middleware/src/agents/subAgentToolHydration.ts b/middleware/src/agents/subAgentToolHydration.ts index 192d1328..c0ae7312 100644 --- a/middleware/src/agents/subAgentToolHydration.ts +++ b/middleware/src/agents/subAgentToolHydration.ts @@ -15,7 +15,7 @@ import { createAnthropicProvider, type AnthropicClient, -} from '@omadia/llm-provider'; +} from '@omadia/llm-adapter-anthropic'; import type { LocalSubAgentTool } from '@omadia/plugin-api'; import { buildSubAgentDomainTools, diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 8c86d02b..63cf4701 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -3,9 +3,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createAnthropicClient, + registerAnthropicAdapter, + type AnthropicClient, +} from '@omadia/llm-adapter-anthropic'; +import { registerOpenAiAdapter } from '@omadia/llm-adapter-openai'; +import { + defaultLlmAdapters, LlmProviderCatalog, readProviderApiKey, - type AnthropicClient, } from '@omadia/llm-provider'; import express from 'express'; import cookieParser from 'cookie-parser'; @@ -297,6 +302,20 @@ async function main(): Promise { // can resolve a plugin-contributed provider at its own activation. const llmProviderCatalog = new LlmProviderCatalog(); serviceRegistry.provide('llmProviderCatalog', llmProviderCatalog); + // Bundled wire-format adapters (issue #298): the llm-provider runtime resolves + // a provider by looking up the adapter for its wire format. The concrete + // adapters + their SDKs live in @omadia/llm-adapter-*; register them into the + // process-default registry HERE, before any provider is resolved, so the core + // package itself imports no vendor SDK. A third-party wire format would add + // another register*Adapter call here (or a plugin registering at activate). + registerAnthropicAdapter(defaultLlmAdapters); + registerOpenAiAdapter(defaultLlmAdapters); + console.log( + `[middleware] ${String(defaultLlmAdapters.list().length)} LLM wire-format adapter(s) registered: ${defaultLlmAdapters + .list() + .map((a) => a.wireFormat) + .join(', ')}`, + ); // Bundled built-in providers (anthropic/openai/mistral). The llm-provider // package ships ZERO static models now; these register into the catalog + // overlay HERE — before plugin activation and before the builder/orchestrator diff --git a/middleware/src/platform/anthropicLlmProvider.ts b/middleware/src/platform/anthropicLlmProvider.ts index 84d8d42e..211e3d90 100644 --- a/middleware/src/platform/anthropicLlmProvider.ts +++ b/middleware/src/platform/anthropicLlmProvider.ts @@ -14,10 +14,12 @@ * no tools — the orchestrator handles tool-loops itself). */ import { - collectText, createAnthropicProvider, - textMessage, type AnthropicClient, +} from '@omadia/llm-adapter-anthropic'; +import { + collectText, + textMessage, type LlmProvider as NeutralLlmProvider, } from '@omadia/llm-provider'; import type { diff --git a/middleware/src/platform/llmProviderManifest.ts b/middleware/src/platform/llmProviderManifest.ts index 9ccc4539..147e6fa2 100644 --- a/middleware/src/platform/llmProviderManifest.ts +++ b/middleware/src/platform/llmProviderManifest.ts @@ -111,8 +111,9 @@ function parseQuirks(raw: unknown): ProviderQuirks | undefined { }; } -/** Parse the optional `policy` block (operator-UI compliance hints). All - * fields optional; non-booleans are ignored so a typo can't flip a default. */ +/** Parse the optional `policy` block (operator-UI compliance hints + the + * keyless flag). All fields optional; non-booleans are ignored so a typo can't + * flip a default. */ function parsePolicy(raw: unknown): ProviderPolicy | undefined { if (raw === undefined) return undefined; const rec = asRecord(raw); diff --git a/middleware/src/plugins/builder/builderAgent.ts b/middleware/src/plugins/builder/builderAgent.ts index c4024cf9..b2ff3559 100644 --- a/middleware/src/plugins/builder/builderAgent.ts +++ b/middleware/src/plugins/builder/builderAgent.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { createAnthropicProvider, type AnthropicClient, -} from '@omadia/llm-provider'; +} from '@omadia/llm-adapter-anthropic'; import { LocalSubAgent, type AskObserver, diff --git a/middleware/src/plugins/builder/previewChatService.ts b/middleware/src/plugins/builder/previewChatService.ts index 59f64b12..4f936ccc 100644 --- a/middleware/src/plugins/builder/previewChatService.ts +++ b/middleware/src/plugins/builder/previewChatService.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { createAnthropicProvider, type AnthropicClient, -} from '@omadia/llm-provider'; +} from '@omadia/llm-adapter-anthropic'; import { LocalSubAgent, type AskObserver, diff --git a/middleware/src/plugins/dynamicAgentRuntime.ts b/middleware/src/plugins/dynamicAgentRuntime.ts index 22aa5fac..d647c638 100644 --- a/middleware/src/plugins/dynamicAgentRuntime.ts +++ b/middleware/src/plugins/dynamicAgentRuntime.ts @@ -3,10 +3,12 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { - coerceModelToProvider, createAnthropicProvider, - resolveLlmProvider, type AnthropicClient, +} from '@omadia/llm-adapter-anthropic'; +import { + coerceModelToProvider, + resolveLlmProvider, type LlmProvider, } from '@omadia/llm-provider'; import type { z } from 'zod'; diff --git a/middleware/test/_helpers/builtinProviders.ts b/middleware/test/_helpers/builtinProviders.ts index 196b698a..c247f8f7 100644 --- a/middleware/test/_helpers/builtinProviders.ts +++ b/middleware/test/_helpers/builtinProviders.ts @@ -6,17 +6,28 @@ * top of a test file (or inside a describe) to register them before each test * and clear the overlay after. */ -import { LlmProviderCatalog, clearExternalModels } from '@omadia/llm-provider'; +import { registerAnthropicAdapter } from '@omadia/llm-adapter-anthropic'; +import { registerOpenAiAdapter } from '@omadia/llm-adapter-openai'; +import { + defaultLlmAdapters, + LlmProviderCatalog, + clearExternalModels, +} from '@omadia/llm-provider'; import { afterEach, beforeEach } from 'node:test'; import { registerBuiltinLlmProviders } from '../../src/platform/builtinLlmProviders.js'; /** Register the bundled built-in providers (anthropic/openai/mistral) into the - * global model overlay before each test; clear it after. Idempotent. */ + * global model overlay before each test; clear it after. Also registers the + * wire-format adapters into the process-default registry so a built provider + * actually resolves (the app does this at boot; tests run without boot). + * Idempotent. */ export function useBuiltinProviders(): void { beforeEach(() => { clearExternalModels(); registerBuiltinLlmProviders(new LlmProviderCatalog()); + registerAnthropicAdapter(defaultLlmAdapters); + registerOpenAiAdapter(defaultLlmAdapters); }); afterEach(() => { clearExternalModels(); diff --git a/middleware/test/buildOrchestrator.test.ts b/middleware/test/buildOrchestrator.test.ts index 0dbc37bb..3c1acda7 100644 --- a/middleware/test/buildOrchestrator.test.ts +++ b/middleware/test/buildOrchestrator.test.ts @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; import { createAnthropicClient, createAnthropicProvider, -} from '@omadia/llm-provider'; +} from '@omadia/llm-adapter-anthropic'; import { InMemoryNudgeRegistry } from '@omadia/plugin-api'; import type { EntityRefBus, diff --git a/middleware/test/llmProviderAnthropicAdapter.test.ts b/middleware/test/llmProviderAnthropicAdapter.test.ts index bbd16b0c..07df9747 100644 --- a/middleware/test/llmProviderAnthropicAdapter.test.ts +++ b/middleware/test/llmProviderAnthropicAdapter.test.ts @@ -12,8 +12,10 @@ import type Anthropic from '@anthropic-ai/sdk'; import { classifyAnthropicError, - collectText, createAnthropicProvider, +} from '@omadia/llm-adapter-anthropic'; +import { + collectText, toolCalls, type LlmStreamEvent, } from '@omadia/llm-provider'; diff --git a/middleware/test/llmProviderFactory.test.ts b/middleware/test/llmProviderFactory.test.ts index 30a1b265..c3414391 100644 --- a/middleware/test/llmProviderFactory.test.ts +++ b/middleware/test/llmProviderFactory.test.ts @@ -6,7 +6,19 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { knownProviderBaseUrl, resolveLlmProvider } from '@omadia/llm-provider'; +import { registerAnthropicAdapter } from '@omadia/llm-adapter-anthropic'; +import { registerOpenAiAdapter } from '@omadia/llm-adapter-openai'; +import { + defaultLlmAdapters, + knownProviderBaseUrl, + resolveLlmProvider, +} from '@omadia/llm-provider'; + +// resolveLlmProvider resolves via the wire-format adapter registry; the app +// registers the bundled adapters at boot. Tests run without boot, so register +// them into the process-default registry here (idempotent). +registerAnthropicAdapter(defaultLlmAdapters); +registerOpenAiAdapter(defaultLlmAdapters); function vaultGet(store: Record) { return (key: string): Promise => diff --git a/middleware/test/llmProviderMinimaxQuirks.test.ts b/middleware/test/llmProviderMinimaxQuirks.test.ts index d2b81d48..1a2c13ba 100644 --- a/middleware/test/llmProviderMinimaxQuirks.test.ts +++ b/middleware/test/llmProviderMinimaxQuirks.test.ts @@ -10,14 +10,25 @@ import { test } from 'node:test'; import type OpenAI from 'openai'; +import { registerAnthropicAdapter } from '@omadia/llm-adapter-anthropic'; import { classifyOpenAiError, createOpenAiProvider, + registerOpenAiAdapter, +} from '@omadia/llm-adapter-openai'; +import { + defaultLlmAdapters, LlmProviderCatalog, resolveLlmProvider, type LlmRequest, } from '@omadia/llm-provider'; +// resolveLlmProvider resolves via the wire-format adapter registry (registered +// at boot in the app). Tests run without boot — register into the process-default +// registry here (idempotent). +registerAnthropicAdapter(defaultLlmAdapters); +registerOpenAiAdapter(defaultLlmAdapters); + interface Captured { params?: Record; } diff --git a/middleware/test/llmProviderOpenAiAdapter.test.ts b/middleware/test/llmProviderOpenAiAdapter.test.ts index 744e52dd..e5c6f30f 100644 --- a/middleware/test/llmProviderOpenAiAdapter.test.ts +++ b/middleware/test/llmProviderOpenAiAdapter.test.ts @@ -15,8 +15,10 @@ import { APIConnectionError, APIConnectionTimeoutError } from 'openai'; import { classifyOpenAiError, - collectText, createOpenAiProvider, +} from '@omadia/llm-adapter-openai'; +import { + collectText, toolCalls, type LlmStreamEvent, } from '@omadia/llm-provider';