diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e0803c9c5..c6f70958e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,26 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — "Sign in with ChatGPT" subscription provider (#294, experimental) + +- **Connect a ChatGPT subscription as an LLM provider via OAuth, no API key.** + A new `openai-chatgpt` provider connects through the real device-code login + flow (`POST /api/v1/admin/providers/oauth/{start,poll}`): the operator gets a + user code, approves it at `auth.openai.com/codex/device`, and the resulting + bearer drives the ChatGPT/Codex **Responses** backend (a new + `openai-responses` SSE wire format + adapter). Gated behind + `CHATGPT_SUBSCRIPTION_EXPERIMENTAL` (off by default) — driving programmatic + calls through a consumer subscription is a ToS grey area, so the connect modal + shows a prominent notice and it is not an enterprise feature. +- **Rotation-safe token store.** Refresh tokens rotate (reuse is a terminal + error), so tokens live in one process-wide store with single-flight refresh; + rotated tokens fan out to every LLM-plugin vault scope with a newest-wins + stamp, and a dead grant parks the provider in a clean "reconnect required" + state instead of retry-hammering. Empirically verified live: tools, forced + tool choice, parallel tool calls and vision all work on the backend. +- Contract `@omadia/llm-provider-api` → 1.1.0 (additive: `openai-responses` + wire format, descriptor `oauth`, adapter `bearerProvider`). + ### Added — group-conversation primitives in the channel SDK + Principal-addressed targeted delivery (#330 Workstream B1) - Channel SDK (strictly additive; Teams 0.12.7 / Telegram 0.2.0 run unchanged): `IncomingTurn.conversationType` (`'direct' | 'group'`, absent = unknown → treated as direct via the new `isGroupConversation`), a `ConversationRoster` contract with `partial` lower-bound semantics, typed `ConversationMembershipEvent`s (`bot_added` incl. WHO invited the agent, `members_added`/`members_removed`), and a `TargetedSendProvider` that only ever delivers to ONE already-resolved user. diff --git a/middleware/.env.example b/middleware/.env.example index 99ba2e60e..3b55f4267 100644 --- a/middleware/.env.example +++ b/middleware/.env.example @@ -272,3 +272,11 @@ CONDUCTOR_EPHEMERAL_MAX_TTL_MS=604800000 CONDUCTOR_EPHEMERAL_MAX_ACTIVE_PER_AGENT=3 CONDUCTOR_EPHEMERAL_MAX_CREATES_PER_HOUR=10 CONDUCTOR_EPHEMERAL_REAPER_INTERVAL_MS=60000 + +# --- Sign in with ChatGPT (#294, EXPERIMENTAL) ------------------------------ +# Registers the `openai-chatgpt` subscription provider: connect via an OAuth +# device flow ("Sign in with ChatGPT") on the Modelle admin page instead of an +# API key. The resulting bearer targets the ChatGPT/Codex Responses backend. +# OFF by default — driving programmatic calls through a consumer ChatGPT +# subscription is a ToS grey area; opt in explicitly and surface the notice. +# CHATGPT_SUBSCRIPTION_EXPERIMENTAL=true diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 3a2a494bf..41d0f6820 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -2236,6 +2236,10 @@ "resolved": "packages/llm-adapter-openai", "link": true }, + "node_modules/@omadia/llm-adapter-openai-responses": { + "resolved": "packages/llm-adapter-openai-responses", + "link": true + }, "node_modules/@omadia/llm-provider": { "resolved": "packages/llm-provider", "link": true @@ -9474,6 +9478,20 @@ "openai": "*" } }, + "packages/llm-adapter-openai-responses": { + "name": "@omadia/llm-adapter-openai-responses", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "devDependencies": { + "@types/node": "^26.2.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/llm-provider": { "name": "@omadia/llm-provider", "version": "0.1.0", diff --git a/middleware/package.json b/middleware/package.json index 1d7ebf9b3..80e0cb6cc 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -20,14 +20,14 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/sandbox && npm run build -w @omadia/publish && 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/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/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/sandbox && npm run build -w @omadia/publish && 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/llm-adapter-openai-responses && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/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/sandbox && npm run build -w @omadia/publish && 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/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/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/sandbox && npm run build -w @omadia/publish && 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/llm-adapter-openai-responses && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/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/harness-sandbox/src/ packages/harness-publish/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-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/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-channel-api/src/ packages/harness-plugin-plan-runner/src/", - "lint:fix": "eslint src/ packages/harness-sandbox/src/ packages/harness-publish/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-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/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-channel-api/src/ packages/harness-plugin-plan-runner/src/ --fix", - "typecheck": "npm run typecheck -w @omadia/sandbox && npm run typecheck -w @omadia/publish && 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/conductor-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit && npm run typecheck:golden && npm run typecheck:adversarial", + "lint": "eslint src/ packages/harness-sandbox/src/ packages/harness-publish/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/llm-adapter-openai-responses/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/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-channel-api/src/ packages/harness-plugin-plan-runner/src/", + "lint:fix": "eslint src/ packages/harness-sandbox/src/ packages/harness-publish/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/llm-adapter-openai-responses/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/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-channel-api/src/ packages/harness-plugin-plan-runner/src/ --fix", + "typecheck": "npm run typecheck -w @omadia/sandbox && npm run typecheck -w @omadia/publish && 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/llm-adapter-openai-responses && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/conductor-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/api-key-auth && 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/embedding-adapter-openai && 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/plugin-privacy-guard && 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/channel-api && 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/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit && npm run typecheck:golden && npm run typecheck:adversarial", "typecheck:golden": "tsc -p test/golden/tsconfig.json", "typecheck:adversarial": "tsc -p test/adversarial/tsconfig.json", "typecheck:test": "node scripts/check-test-typecheck.mjs", diff --git a/middleware/packages/llm-adapter-openai-responses/package.json b/middleware/packages/llm-adapter-openai-responses/package.json new file mode 100644 index 000000000..410e7e6f2 --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/package.json @@ -0,0 +1,23 @@ +{ + "name": "@omadia/llm-adapter-openai-responses", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "description": "OpenAI Responses wire-format adapter (SSE) for the omadia LLM provider seam — speaks the ChatGPT/Codex backend that subscription OAuth bearers are scoped to (#294, experimental). Implements the @omadia/llm-provider-api LlmAdapter contract (wireFormat 'openai-responses') with raw fetch + an in-package SSE parser; no vendor SDK. Register at boot via registerOpenAiResponsesAdapter.", + "license": "MIT", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@omadia/llm-provider-api": "*" + }, + "devDependencies": { + "@types/node": "^26.2.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/middleware/packages/llm-adapter-openai-responses/src/adapter.ts b/middleware/packages/llm-adapter-openai-responses/src/adapter.ts new file mode 100644 index 000000000..5570dd962 --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/src/adapter.ts @@ -0,0 +1,41 @@ +/** + * OpenAI Responses (SSE) wire-format adapter registration (#294, experimental). + * + * Wraps `createOpenAiResponsesProvider` in the neutral `LlmAdapter` contract so + * the resolution seam in `@omadia/llm-provider` can build the ChatGPT/Codex- + * backend provider from a descriptor + an OAuth `bearerProvider` without any + * vendor SDK. + */ +import type { + LlmAdapter, + LlmAdapterBuildOptions, + LlmAdapterRegistry, + LlmProvider, +} from '@omadia/llm-provider-api'; + +import { createOpenAiResponsesProvider } from './responsesProvider.js'; + +export const openAiResponsesAdapter: LlmAdapter = { + wireFormat: 'openai-responses', + build(opts: LlmAdapterBuildOptions): LlmProvider { + if (opts.baseURL === undefined) { + throw new Error('openai-responses adapter requires a baseURL'); + } + return createOpenAiResponsesProvider({ + baseURL: opts.baseURL, + ...(opts.bearerProvider !== undefined + ? { bearerProvider: opts.bearerProvider } + : {}), + ...(opts.apiKey.length > 0 ? { apiKey: opts.apiKey } : {}), + ...(opts.id !== undefined ? { id: opts.id } : {}), + ...(opts.log !== undefined ? { log: opts.log } : {}), + }); + }, +}; + +/** Register the Responses adapter into a registry (call once at boot). */ +export function registerOpenAiResponsesAdapter( + registry: LlmAdapterRegistry, +): void { + registry.register(openAiResponsesAdapter); +} diff --git a/middleware/packages/llm-adapter-openai-responses/src/index.ts b/middleware/packages/llm-adapter-openai-responses/src/index.ts new file mode 100644 index 000000000..0430b9914 --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/src/index.ts @@ -0,0 +1,19 @@ +/** + * `@omadia/llm-adapter-openai-responses` — the OpenAI Responses (SSE) wire- + * format adapter for the ChatGPT/Codex backend (#294 "Sign in with ChatGPT", + * EXPERIMENTAL). Raw fetch + in-package SSE parser; no vendor SDK. The app + * registers it into the LLM adapter registry at boot + * (`registerOpenAiResponsesAdapter`). + */ +export { + openAiResponsesAdapter, + registerOpenAiResponsesAdapter, +} from './adapter.js'; + +export { + createOpenAiResponsesProvider, + ResponsesHttpError, + type OpenAiResponsesProviderOptions, +} from './responsesProvider.js'; + +export { SseParser, type SseEvent } from './sse.js'; diff --git a/middleware/packages/llm-adapter-openai-responses/src/responsesProvider.ts b/middleware/packages/llm-adapter-openai-responses/src/responsesProvider.ts new file mode 100644 index 000000000..822d83f08 --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/src/responsesProvider.ts @@ -0,0 +1,385 @@ +/** + * OpenAI Responses provider over raw fetch + SSE — the wire the ChatGPT/Codex + * backend speaks for subscription OAuth bearers (#294, EXPERIMENTAL). + * + * Verified live against `https://chatgpt.com/backend-api/codex/responses` + * (2026-08-21): requires `stream:true` + the `OpenAI-Beta: responses= + * experimental`, `originator` and per-request `session_id` headers; supports + * custom function tools, forced `tool_choice`, parallel tool calls and + * `input_image` parts; `instructions` is optional. `store:false` is forced — + * omadia never persists conversations server-side. + * + * No vendor SDK on purpose: the backend is not a public API surface, so an + * SDK's param validation would fight it, and raw fetch keeps the npm-audit + * surface at zero. The token can ROTATE mid-process, so the bearer is resolved + * per request via `bearerProvider` — never captured at build time. + */ +import { randomUUID } from 'node:crypto'; + +import type { + ContentPart, + FinishReason, + LlmErrorClassification, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStreamEvent, + LlmUsage, + ToolChoice, +} from '@omadia/llm-provider-api'; + +import { SseParser } from './sse.js'; + +export interface OpenAiResponsesProviderOptions { + /** e.g. `https://chatgpt.com/backend-api/codex` — `/responses` is appended. */ + readonly baseURL: string; + /** Per-request bearer (OAuth path — preferred; may refresh/rotate). */ + readonly bearerProvider?: () => Promise; + /** Static fallback credential when no `bearerProvider` is set. */ + readonly apiKey?: string; + readonly id?: string; + readonly fetchImpl?: typeof fetch; + readonly log?: (...args: unknown[]) => void; +} + +/** HTTP failure carrying the status for `classifyError`. */ +export class ResponsesHttpError extends Error { + constructor( + readonly status: number, + body: string, + ) { + super(`openai-responses: HTTP ${String(status)}: ${body.slice(0, 300)}`); + } +} + +function asRecord(v: unknown): Record { + return v !== null && typeof v === 'object' ? (v as Record) : {}; +} + +function systemText(system: LlmRequest['system']): string | undefined { + if (system === undefined) return undefined; + if (typeof system === 'string') return system.length > 0 ? system : undefined; + const text = system.map((b) => b.text).join('\n\n'); + return text.length > 0 ? text : undefined; +} + +function toolResultText( + content: string | ReadonlyArray<{ type: string; text?: string }>, +): string { + if (typeof content === 'string') return content; + const texts: string[] = []; + for (const part of content) { + if (part.type === 'text' && typeof part.text === 'string') { + texts.push(part.text); + } else { + // Contract rule: never drop non-text tool results silently. + throw new Error( + 'openai-responses: image tool results are not supported by the Responses wire mapping', + ); + } + } + return texts.join('\n'); +} + +/** Neutral messages → Responses `input` items. */ +function toInputItems(req: LlmRequest): unknown[] { + const items: unknown[] = []; + for (const message of req.messages) { + const contentParts: unknown[] = []; + const flush = (): void => { + if (contentParts.length === 0) return; + items.push({ + type: 'message', + role: message.role, + content: [...contentParts], + }); + contentParts.length = 0; + }; + for (const part of message.content) { + switch (part.type) { + case 'text': + contentParts.push( + message.role === 'assistant' + ? { type: 'output_text', text: part.text } + : { type: 'input_text', text: part.text }, + ); + break; + case 'image': + contentParts.push({ + type: 'input_image', + image_url: `data:${part.mediaType};base64,${part.data}`, + }); + break; + case 'tool_call': + // Function calls are top-level items, not message content. + flush(); + items.push({ + type: 'function_call', + call_id: part.id, + name: part.name, + arguments: JSON.stringify(part.input ?? {}), + }); + break; + case 'tool_result': + flush(); + items.push({ + type: 'function_call_output', + call_id: part.toolCallId, + output: toolResultText(part.content), + }); + break; + } + } + flush(); + } + return items; +} + +function toToolChoice(choice: ToolChoice): unknown { + switch (choice.type) { + case 'auto': + return 'auto'; + case 'none': + return 'none'; + case 'required': + return 'required'; + case 'tool': + return { type: 'function', name: choice.name }; + } +} + +function toRequestBody(req: LlmRequest): Record { + const instructions = systemText(req.system); + const tools = (req.tools ?? []) + // Server/built-in tools are vendor-owned schemas we cannot forward here. + .filter((t) => t.serverType === undefined) + .map((t) => ({ + type: 'function', + name: t.name, + description: t.description, + parameters: t.inputSchema, + strict: false, + })); + const disableParallel = + req.toolChoice !== undefined && + 'disableParallel' in req.toolChoice && + req.toolChoice.disableParallel === true; + return { + model: req.model, + ...(instructions !== undefined ? { instructions } : {}), + input: toInputItems(req), + max_output_tokens: req.maxTokens, + ...(req.temperature !== undefined ? { temperature: req.temperature } : {}), + ...(tools.length > 0 ? { tools } : {}), + ...(req.toolChoice !== undefined + ? { tool_choice: toToolChoice(req.toolChoice) } + : {}), + ...(disableParallel ? { parallel_tool_calls: false } : {}), + // Backend contract: stream-only, and omadia never stores server-side. + stream: true, + store: false, + }; +} + +function num(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function toUsage(usage: unknown): LlmUsage { + const u = asRecord(usage); + return { + inputTokens: num(u['input_tokens']) ?? 0, + outputTokens: num(u['output_tokens']) ?? 0, + }; +} + +function toFinalResponse(payload: unknown, fallbackModel: string): LlmResponse { + const response = asRecord(asRecord(payload)['response']); + const output = Array.isArray(response['output']) ? response['output'] : []; + const content: ContentPart[] = []; + let sawToolCall = false; + for (const rawItem of output) { + const item = asRecord(rawItem); + const type = str(item['type']); + if (type === 'message') { + const parts = Array.isArray(item['content']) ? item['content'] : []; + for (const rawPart of parts) { + const part = asRecord(rawPart); + if (str(part['type']) === 'output_text') { + content.push({ type: 'text', text: String(part['text'] ?? '') }); + } + } + } else if (type === 'function_call') { + sawToolCall = true; + let input: unknown = {}; + const args = str(item['arguments']); + if (args !== undefined) { + try { + input = JSON.parse(args); + } catch { + input = { __raw: args }; + } + } + content.push({ + type: 'tool_call', + id: str(item['call_id']) ?? str(item['id']) ?? randomUUID(), + name: str(item['name']) ?? 'unknown_tool', + input, + }); + } + // reasoning / other item types: not part of the neutral contract. + } + const status = str(response['status']); + const incompleteReason = str(asRecord(response['incomplete_details'])['reason']); + const finishReason: FinishReason = sawToolCall + ? 'tool_calls' + : incompleteReason === 'max_output_tokens' + ? 'max_tokens' + : 'stop'; + return { + content, + finishReason, + ...(status !== undefined + ? { providerFinishReason: incompleteReason ?? status } + : {}), + model: str(response['model']) ?? fallbackModel, + usage: toUsage(response['usage']), + }; +} + +export function createOpenAiResponsesProvider( + options: OpenAiResponsesProviderOptions, +): LlmProvider { + const fetchImpl = options.fetchImpl ?? fetch; + const url = `${options.baseURL.replace(/\/+$/, '')}/responses`; + + async function bearer(): Promise { + if (options.bearerProvider) return options.bearerProvider(); + if (options.apiKey !== undefined && options.apiKey.length > 0) { + return options.apiKey; + } + throw new Error('openai-responses: no credential configured'); + } + + async function* streamImpl(req: LlmRequest): AsyncGenerator { + const token = await bearer(); + const res = await fetchImpl(url, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + accept: 'text/event-stream', + // Live-verified header set — without these the backend 4xxes. + 'openai-beta': 'responses=experimental', + originator: 'codex_cli_rs', + session_id: randomUUID(), + }, + body: JSON.stringify(toRequestBody(req)), + }); + if (!res.ok || res.body === null) { + const body = await res.text().catch(() => ''); + throw new ResponsesHttpError(res.status, body); + } + + const parser = new SseParser(); + const decoder = new TextDecoder(); + let finalEvent: LlmStreamEvent | undefined; + for await (const chunk of res.body as unknown as AsyncIterable) { + for (const evt of parser.push(decoder.decode(chunk, { stream: true }))) { + const payload = ((): unknown => { + try { + return JSON.parse(evt.data); + } catch { + return {}; + } + })(); + const p = asRecord(payload); + switch (evt.event) { + case 'response.output_text.delta': { + const delta = str(p['delta']); + if (delta !== undefined) yield { type: 'text_delta', text: delta }; + break; + } + case 'response.output_item.added': { + if (str(asRecord(p['item'])['type']) === 'function_call') { + yield { type: 'tool_use_start' }; + } + break; + } + case 'response.function_call_arguments.delta': { + const delta = str(p['delta']); + if (delta !== undefined) { + yield { type: 'tool_input_delta', text: delta }; + } + break; + } + case 'response.completed': { + finalEvent = { + type: 'final', + response: toFinalResponse(payload, req.model), + }; + break; + } + case 'response.failed': + case 'error': { + const message = + str(asRecord(p['error'])['message']) ?? + str(asRecord(asRecord(p['response'])['error'])['message']) ?? + 'stream reported failure'; + throw new Error(`openai-responses: ${message}`); + } + default: + break; // created / in_progress / content_part.* / *.done — no-ops + } + } + } + if (finalEvent === undefined) { + throw new Error('openai-responses: stream ended without response.completed'); + } + yield finalEvent; + } + + return { + id: options.id ?? 'openai-responses', + capabilities: { + tools: true, + vision: true, + streaming: true, + promptCaching: false, + forcedToolChoice: true, + parallelToolCalls: true, + // One-or-the-other per completion has not been disproven live; stay + // conservative so the orchestrator compensates with its post-turn pass. + interleavedToolUse: false, + }, + stream: (req: LlmRequest) => streamImpl(req), + async complete(req: LlmRequest): Promise { + for await (const evt of streamImpl(req)) { + if (evt.type === 'final') return evt.response; + } + throw new Error('openai-responses: stream ended without a final event'); + }, + classifyError(err: unknown): LlmErrorClassification { + if (err instanceof ResponsesHttpError) { + if (err.status === 401 || err.status === 403) { + return { retryable: false, kind: 'auth' }; + } + if (err.status === 429) return { retryable: true, kind: 'rate_limit' }; + if (err.status >= 500) return { retryable: true, kind: 'overloaded' }; + return { retryable: false, kind: 'other' }; + } + // A dead OAuth grant surfaces from the bearerProvider (token store). + if ( + err instanceof Error && + err.name === 'OAuthReconnectRequiredError' + ) { + return { retryable: false, kind: 'auth' }; + } + return { retryable: false, kind: 'other' }; + }, + }; +} diff --git a/middleware/packages/llm-adapter-openai-responses/src/sse.ts b/middleware/packages/llm-adapter-openai-responses/src/sse.ts new file mode 100644 index 000000000..1e215a3cc --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/src/sse.ts @@ -0,0 +1,54 @@ +/** + * Minimal incremental Server-Sent-Events parser. + * + * Feed raw text chunks (arbitrary split points — a frame may arrive across + * chunks); complete events come out. Only the `event:` and `data:` fields are + * needed for the Responses stream; comments and other fields are ignored. + * Multi-line `data:` values are joined with `\n` per the SSE spec. + */ + +export interface SseEvent { + readonly event: string; + readonly data: string; +} + +export class SseParser { + private buffer = ''; + private eventName = ''; + private dataLines: string[] = []; + + /** Parse a chunk; returns every event completed by it. */ + push(chunk: string): SseEvent[] { + this.buffer += chunk; + const events: SseEvent[] = []; + for (;;) { + const nl = this.buffer.indexOf('\n'); + if (nl === -1) break; + let line = this.buffer.slice(0, nl); + this.buffer = this.buffer.slice(nl + 1); + if (line.endsWith('\r')) line = line.slice(0, -1); + + if (line === '') { + // Blank line = dispatch. + if (this.dataLines.length > 0 || this.eventName !== '') { + events.push({ + event: this.eventName || 'message', + data: this.dataLines.join('\n'), + }); + } + this.eventName = ''; + this.dataLines = []; + continue; + } + if (line.startsWith(':')) continue; // comment + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'event') this.eventName = value; + else if (field === 'data') this.dataLines.push(value); + // id / retry / unknown fields: ignored. + } + return events; + } +} diff --git a/middleware/packages/llm-adapter-openai-responses/tsconfig.json b/middleware/packages/llm-adapter-openai-responses/tsconfig.json new file mode 100644 index 000000000..c1f038616 --- /dev/null +++ b/middleware/packages/llm-adapter-openai-responses/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "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/src/adapter.ts b/middleware/packages/llm-provider-api/src/adapter.ts index 7cf87390b..ca015bed8 100644 --- a/middleware/packages/llm-provider-api/src/adapter.ts +++ b/middleware/packages/llm-provider-api/src/adapter.ts @@ -29,6 +29,10 @@ export interface LlmAdapterBuildOptions { readonly id?: string; /** OpenAI-adapter vendor quirks from the descriptor; ignored by other adapters. */ readonly quirks?: ProviderQuirks; + /** Resolves a fresh bearer per request (OAuth-connected providers, #294). + * When set, adapters that support it MUST prefer it over `apiKey` — the + * token can rotate mid-process, so it must never be captured at build time. */ + readonly bearerProvider?: () => Promise; readonly log?: (...args: unknown[]) => void; } diff --git a/middleware/packages/llm-provider-api/src/descriptor.ts b/middleware/packages/llm-provider-api/src/descriptor.ts index addf3c995..9cd043751 100644 --- a/middleware/packages/llm-provider-api/src/descriptor.ts +++ b/middleware/packages/llm-provider-api/src/descriptor.ts @@ -13,8 +13,14 @@ import type { ModelInfo } from './models.js'; * `openai-compatible` = OpenAI Chat Completions (most providers); `anthropic` = * Anthropic Messages (Claude, or an Anthropic-compatible gateway); `claude-cli` * = not HTTP at all but the local official `claude` CLI driven as a tool-less - * completion endpoint on a subscription (#309 Shape 2, keyless). */ -export type WireFormat = 'openai-compatible' | 'anthropic' | 'claude-cli'; + * completion endpoint on a subscription (#309 Shape 2, keyless); + * `openai-responses` = the OpenAI Responses wire protocol over SSE, as spoken + * by the ChatGPT/Codex backend for subscription bearers (#294, experimental). */ +export type WireFormat = + | 'openai-compatible' + | 'anthropic' + | 'claude-cli' + | 'openai-responses'; /** Vendor deviations from plain OpenAI that the OpenAI adapter handles when set. */ export interface ProviderQuirks { @@ -65,5 +71,9 @@ export interface LlmProviderDescriptor { /** 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; + /** Declared when the provider connects via an OAuth login instead of (or in + * addition to) an API key. `device` = the device-code flow the admin + * connect routes drive (#294 "Sign in with ChatGPT", experimental). */ + readonly oauth?: { readonly kind: 'device' }; readonly models: ReadonlyArray; } diff --git a/middleware/packages/llm-provider-api/src/version.ts b/middleware/packages/llm-provider-api/src/version.ts index 83f879afb..8ee1caf91 100644 --- a/middleware/packages/llm-provider-api/src/version.ts +++ b/middleware/packages/llm-provider-api/src/version.ts @@ -4,4 +4,4 @@ * 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; +export const LLM_PROVIDER_API_VERSION = '1.1.0' as const; diff --git a/middleware/packages/llm-provider/src/index.ts b/middleware/packages/llm-provider/src/index.ts index 31de53026..d349cb5fb 100644 --- a/middleware/packages/llm-provider/src/index.ts +++ b/middleware/packages/llm-provider/src/index.ts @@ -46,10 +46,41 @@ export { export { legacyProviderApiKeyVaultKey, providerApiKeyVaultKey, + providerOAuthVaultKeys, readProviderApiKey, + readProviderOAuthTokens, + readProviderOAuthUpdatedAt, + writeProviderOAuthTokens, } from './providerCredentials.js'; +// ---- Runtime: OAuth token store (process-wide, rotation-safe) ---- +export { + getProviderOAuthBearer, + isProviderOAuthReconnectRequired, + primeProviderOAuthTokens, + registerProviderOAuthStoreBinding, + __resetProviderOAuthTokenStore, + type ProviderOAuthDeps, + type ProviderOAuthStoreBinding, +} from './providerOAuthTokenStore.js'; + // ---- Runtime: provider resolution (registry lookup) ---- +export { + OPENAI_CODEX_OAUTH, + OAuthReconnectRequiredError, + exchangeAuthorizationCode, + isAccessTokenExpired, + jwtExpiryMs, + pollDeviceToken, + refreshAccessToken, + requestUserCode, + type FetchLike, + type OAuthClientConfig, + type OAuthTokens, + type PollResult, + type UserCodeGrant, +} from './oauthDeviceFlow.js'; + export { knownProviderBaseUrl, resolveLlmProvider, diff --git a/middleware/packages/llm-provider/src/oauthDeviceFlow.ts b/middleware/packages/llm-provider/src/oauthDeviceFlow.ts new file mode 100644 index 000000000..d698aa0cc --- /dev/null +++ b/middleware/packages/llm-provider/src/oauthDeviceFlow.ts @@ -0,0 +1,345 @@ +/** + * "Sign in with ChatGPT" device-code login (phase 4b — EXPERIMENTAL). + * + * ⚠️ EXPERIMENTAL / ToS GREY AREA. Using a ChatGPT-subscription login to drive + * programmatic API calls lives in a grey area of OpenAI's terms — surface a + * clear notice in the UI and do not market it as an enterprise feature. + * + * This is NOT RFC 8628. The live protocol (verified 2026-08-21 against + * auth.openai.com and the open-source codex CLI, codex-rs/login/src/ + * device_code_auth.rs + server.rs) is a custom three-step flow: + * + * 1. `POST {issuer}/api/accounts/deviceauth/usercode`, JSON `{client_id}` + * → `{device_auth_id, user_code, interval, expires_at}`. The operator + * visits `{issuer}/codex/device` and enters the user code. + * 2. Poll `POST {issuer}/api/accounts/deviceauth/token`, JSON + * `{device_auth_id, user_code}` — non-2xx while pending; on approval + * → `{authorization_code, code_challenge, code_verifier}` (PKCE is + * generated SERVER-side). + * 3. `POST {issuer}/oauth/token`, form-encoded authorization-code exchange + * with `redirect_uri={issuer}/deviceauth/callback` and the server-supplied + * `code_verifier` → `{id_token, access_token, refresh_token}`. + * + * Refresh: `POST {issuer}/oauth/token` with a JSON body + * `{client_id, grant_type:"refresh_token", refresh_token}`. The server MAY + * rotate the refresh token (reuse of a rotated one is a terminal + * `refresh_token_reused` error), so callers must persist rotated tokens — + * see `providerOAuthTokenStore`. + * + * Every function takes its config + a `fetch` implementation so tests never + * depend on the live endpoints. All calls send an explicit browser-like + * User-Agent: the default Node/python UA gets a Cloudflare challenge page. + */ + +/** OpenAI Codex public OAuth client configuration. */ +export interface OAuthClientConfig { + /** Auth issuer base URL; all endpoint paths derive from it. */ + readonly issuer: string; + readonly clientId: string; + /** Explicit UA — required, default UAs are Cloudflare-challenged. */ + readonly userAgent: string; +} + +/** Default OpenAI Codex client (issuer verified live 2026-08-21). */ +export const OPENAI_CODEX_OAUTH: OAuthClientConfig = { + issuer: 'https://auth.openai.com', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) omadia-oauth', +}; + +/** Step-1 result: what the operator needs + what the poll needs. */ +export interface UserCodeGrant { + readonly deviceAuthId: string; + readonly userCode: string; + /** Human verification URL (client-constructed: `{issuer}/codex/device`). */ + readonly verificationUri: string; + /** Minimum seconds between polls. */ + readonly interval: number; + /** Absolute epoch-ms expiry of the device code, when the server sends one. */ + readonly expiresAtMs?: number; +} + +export interface OAuthTokens { + readonly accessToken: string; + readonly refreshToken?: string; + /** Absolute epoch-ms expiry (from `expires_in`, else the JWT `exp` claim). */ + readonly expiresAt?: number; + readonly tokenType?: string; + readonly idToken?: string; +} + +/** Outcome of a single step-2 poll. */ +export type PollResult = + /** Approved — carry the server-generated PKCE verifier into step 3. */ + | { + readonly status: 'complete'; + readonly authorizationCode: string; + readonly codeVerifier: string; + } + /** Not approved yet (any non-2xx) — poll again after `interval`. The caller + * owns the deadline (`expiresAtMs` / 15-min cap) and maps it to expired. */ + | { readonly status: 'pending' }; + +export type FetchLike = ( + url: string, + init: { + method: string; + headers: Record; + body: string; + }, +) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +/** Wall clock injected so token-expiry math is deterministic in tests. */ +export type NowMs = () => number; + +/** Refresh failed terminally — the stored grant is dead; reconnect required. */ +export class OAuthReconnectRequiredError extends Error { + constructor(readonly code: string) { + super(`oauth: refresh grant is no longer usable (${code}) — sign in again`); + // Set explicitly: subclass constructors do NOT set `name` (it stays + // 'Error'), and the openai-responses adapter classifies a dead grant by + // `err.name` across a package boundary where `instanceof` can't reach. + this.name = 'OAuthReconnectRequiredError'; + } +} + +function form(params: Record): string { + return Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); +} + +function asRecord(v: unknown): Record { + return v !== null && typeof v === 'object' ? (v as Record) : {}; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function num(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +} + +/** Decode a base64url segment to a UTF-8 string, without a node/DOM global + * (this package is deliberately SDK- and node-types-free like its siblings). */ +function base64UrlToString(input: string): string { + const b64 = input.replace(/-/g, '+').replace(/_/g, '/'); + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const bytes: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of b64) { + const idx = chars.indexOf(ch); + if (idx === -1) continue; // padding '=' and stray chars + buffer = (buffer << 6) | idx; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + // Minimal UTF-8 decode (JWT payloads are ASCII/JSON in practice). + let out = ''; + for (let i = 0; i < bytes.length; ) { + const b0 = bytes[i]!; + if (b0 < 0x80) { + out += String.fromCharCode(b0); + i += 1; + } else if (b0 < 0xe0) { + out += String.fromCharCode(((b0 & 0x1f) << 6) | (bytes[i + 1]! & 0x3f)); + i += 2; + } else { + out += String.fromCharCode( + ((b0 & 0x0f) << 12) | ((bytes[i + 1]! & 0x3f) << 6) | (bytes[i + 2]! & 0x3f), + ); + i += 3; + } + } + return out; +} + +/** Decode the `exp` claim (epoch seconds → ms) of a JWT. No verification — + * this is our own token and only feeds proactive-refresh timing. */ +export function jwtExpiryMs(token: string): number | undefined { + const parts = token.split('.'); + if (parts.length !== 3 || parts[1] === undefined) return undefined; + try { + const payload = asRecord(JSON.parse(base64UrlToString(parts[1]))); + const exp = num(payload['exp']); + return exp !== undefined ? exp * 1000 : undefined; + } catch { + return undefined; + } +} + +function toTokens(body: unknown, nowMs: number): OAuthTokens { + const b = asRecord(body); + const accessToken = str(b['access_token']); + if (accessToken === undefined) { + throw new Error('oauth: token response missing access_token'); + } + const expiresIn = num(b['expires_in']); + const expiresAt = + expiresIn !== undefined ? nowMs + expiresIn * 1000 : jwtExpiryMs(accessToken); + return { + accessToken, + ...(str(b['refresh_token']) !== undefined + ? { refreshToken: str(b['refresh_token']) } + : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(str(b['token_type']) !== undefined ? { tokenType: str(b['token_type']) } : {}), + ...(str(b['id_token']) !== undefined ? { idToken: str(b['id_token']) } : {}), + }; +} + +function baseHeaders(config: OAuthClientConfig): Record { + return { 'user-agent': config.userAgent, accept: 'application/json' }; +} + +function issuerUrl(config: OAuthClientConfig, path: string): string { + return `${config.issuer.replace(/\/+$/, '')}${path}`; +} + +/** Step 1 — request a user code. The operator then visits `verificationUri` + * and enters `userCode`. */ +export async function requestUserCode( + fetchImpl: FetchLike, + config: OAuthClientConfig, +): Promise { + const res = await fetchImpl(issuerUrl(config, '/api/accounts/deviceauth/usercode'), { + method: 'POST', + headers: { ...baseHeaders(config), 'content-type': 'application/json' }, + body: JSON.stringify({ client_id: config.clientId }), + }); + if (!res.ok) { + throw new Error(`oauth: user-code request failed (${String(res.status)})`); + } + const b = asRecord(await res.json()); + const deviceAuthId = str(b['device_auth_id']); + const userCode = str(b['user_code']) ?? str(b['usercode']); + if (deviceAuthId === undefined || userCode === undefined) { + throw new Error('oauth: user-code response missing codes'); + } + const expiresAt = str(b['expires_at']); + const expiresAtMs = expiresAt !== undefined ? Date.parse(expiresAt) : NaN; + return { + deviceAuthId, + userCode, + verificationUri: issuerUrl(config, '/codex/device'), + interval: num(b['interval']) ?? 5, + ...(Number.isFinite(expiresAtMs) ? { expiresAtMs } : {}), + }; +} + +/** Step 2 — poll once for approval. Non-2xx means "not approved yet"; the + * caller owns the deadline and turns it into `expired`. */ +export async function pollDeviceToken( + fetchImpl: FetchLike, + config: OAuthClientConfig, + grant: Pick, +): Promise { + const res = await fetchImpl(issuerUrl(config, '/api/accounts/deviceauth/token'), { + method: 'POST', + headers: { ...baseHeaders(config), 'content-type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: grant.deviceAuthId, + user_code: grant.userCode, + }), + }); + if (!res.ok) { + return { status: 'pending' }; + } + const b = asRecord(await res.json()); + const authorizationCode = str(b['authorization_code']); + const codeVerifier = str(b['code_verifier']); + if (authorizationCode === undefined || codeVerifier === undefined) { + throw new Error('oauth: approval response missing authorization code'); + } + return { status: 'complete', authorizationCode, codeVerifier }; +} + +/** Step 3 — exchange the approval for tokens (form-encoded, per the CLI). */ +export async function exchangeAuthorizationCode( + fetchImpl: FetchLike, + config: OAuthClientConfig, + approval: { authorizationCode: string; codeVerifier: string }, + nowMs: NowMs, +): Promise { + const res = await fetchImpl(issuerUrl(config, '/oauth/token'), { + method: 'POST', + headers: { + ...baseHeaders(config), + 'content-type': 'application/x-www-form-urlencoded', + }, + body: form({ + grant_type: 'authorization_code', + code: approval.authorizationCode, + redirect_uri: issuerUrl(config, '/deviceauth/callback'), + client_id: config.clientId, + code_verifier: approval.codeVerifier, + }), + }); + if (!res.ok) { + throw new Error(`oauth: code exchange failed (${String(res.status)})`); + } + return toTokens(await res.json(), nowMs()); +} + +/** Error codes auth.openai.com uses for a dead refresh grant (from the CLI's + * classify_refresh_token_failure). */ +const TERMINAL_REFRESH_CODES = new Set([ + 'refresh_token_expired', + 'refresh_token_reused', + 'refresh_token_invalidated', + 'invalid_grant', +]); + +/** Refresh an expired access token (JSON body, per the CLI — NOT form-encoded). + * The server may rotate the refresh token; when it does, the OLD one is dead + * and the returned one MUST be persisted. Terminal grant failures throw + * {@link OAuthReconnectRequiredError}. */ +export async function refreshAccessToken( + fetchImpl: FetchLike, + config: OAuthClientConfig, + refreshToken: string, + nowMs: NowMs, +): Promise { + const res = await fetchImpl(issuerUrl(config, '/oauth/token'), { + method: 'POST', + headers: { ...baseHeaders(config), 'content-type': 'application/json' }, + body: JSON.stringify({ + client_id: config.clientId, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }); + if (!res.ok) { + const body = asRecord(await res.json().catch(() => ({}))); + const code = + str(asRecord(body['error'])['code']) ?? str(body['error']) ?? 'unknown'; + if (TERMINAL_REFRESH_CODES.has(code)) { + throw new OAuthReconnectRequiredError(code); + } + throw new Error(`oauth: token refresh failed (${String(res.status)}: ${code})`); + } + const tokens = toTokens(await res.json(), nowMs()); + // Preserve the existing refresh token when the server doesn't rotate it. + return tokens.refreshToken !== undefined + ? tokens + : { ...tokens, refreshToken }; +} + +/** True when the access token is missing or within `skewMs` of expiry (default + * 60s) and should be refreshed before use. Tokens with no known expiry are + * treated as still valid (the API call will surface a 401 if not). */ +export function isAccessTokenExpired( + tokens: OAuthTokens | undefined, + nowMs: number, + skewMs = 60_000, +): boolean { + if (tokens?.accessToken === undefined) return true; + if (tokens.expiresAt === undefined) return false; + return nowMs >= tokens.expiresAt - skewMs; +} diff --git a/middleware/packages/llm-provider/src/providerCredentials.ts b/middleware/packages/llm-provider/src/providerCredentials.ts index 9851525e7..13747bba8 100644 --- a/middleware/packages/llm-provider/src/providerCredentials.ts +++ b/middleware/packages/llm-provider/src/providerCredentials.ts @@ -16,8 +16,14 @@ * break the Anthropic default path. */ +import type { OAuthTokens } from './oauthDeviceFlow.js'; + const PROVIDER_KEY_NAMESPACE = 'provider:'; const API_KEY_LEAF = 'api_key'; +const OAUTH_ACCESS_LEAF = 'oauth_access_token'; +const OAUTH_REFRESH_LEAF = 'oauth_refresh_token'; +const OAUTH_EXPIRES_LEAF = 'oauth_expires_at'; +const OAUTH_UPDATED_LEAF = 'oauth_updated_at'; /** Canonical vault key for a provider's API key: `provider:anthropic/api_key`. */ export function providerApiKeyVaultKey(providerId: string): string { @@ -56,3 +62,87 @@ export async function readProviderApiKey( } return undefined; } + +// --------------------------------------------------------------------------- +// OAuth tokens (phase 4b — "Sign in with ChatGPT"). Stored alongside the +// api_key under the same provider namespace so a provider can be connected +// either way. EXPERIMENTAL — see oauthDeviceFlow.ts for the ToS/audience caveat. +// --------------------------------------------------------------------------- + +/** Canonical vault keys for a provider's OAuth tokens. */ +export function providerOAuthVaultKeys(providerId: string): { + access: string; + refresh: string; + expiresAt: string; + /** Epoch-ms of the last write — newest-wins hydration across vault scopes + * (refresh-token ROTATION makes divergent copies dangerous; see + * providerOAuthTokenStore.ts). */ + updatedAt: string; +} { + const base = `${PROVIDER_KEY_NAMESPACE}${providerId}`; + return { + access: `${base}/${OAUTH_ACCESS_LEAF}`, + refresh: `${base}/${OAUTH_REFRESH_LEAF}`, + expiresAt: `${base}/${OAUTH_EXPIRES_LEAF}`, + updatedAt: `${base}/${OAUTH_UPDATED_LEAF}`, + }; +} + +/** Read a provider's stored OAuth tokens, or `undefined` when no access token + * is present. `get` is the scope-bound vault read. */ +export async function readProviderOAuthTokens( + get: (key: string) => Promise, + providerId: string, +): Promise { + const keys = providerOAuthVaultKeys(providerId); + const accessToken = (await get(keys.access))?.trim(); + if (accessToken === undefined || accessToken.length === 0) return undefined; + const refreshToken = (await get(keys.refresh))?.trim(); + const expiresRaw = (await get(keys.expiresAt))?.trim(); + const expiresAt = + expiresRaw !== undefined && expiresRaw.length > 0 + ? Number.parseInt(expiresRaw, 10) + : undefined; + return { + accessToken, + ...(refreshToken !== undefined && refreshToken.length > 0 + ? { refreshToken } + : {}), + ...(expiresAt !== undefined && Number.isFinite(expiresAt) ? { expiresAt } : {}), + }; +} + +/** Persist a provider's OAuth tokens. `set` is the scope-bound vault write. + * Optional fields are blanked when absent so stale refresh/expiry values do not + * survive a later rewrite and get read back as current tokens. `updatedAtMs` + * stamps the write for newest-wins hydration (defaults to the wall clock). */ +export async function writeProviderOAuthTokens( + set: (key: string, value: string) => Promise, + providerId: string, + tokens: OAuthTokens, + updatedAtMs?: number, +): Promise { + const keys = providerOAuthVaultKeys(providerId); + await set(keys.access, tokens.accessToken); + if (tokens.refreshToken !== undefined) { + await set(keys.refresh, tokens.refreshToken); + } else { + await set(keys.refresh, ''); + } + if (tokens.expiresAt !== undefined) { + await set(keys.expiresAt, String(tokens.expiresAt)); + } else { + await set(keys.expiresAt, ''); + } + await set(keys.updatedAt, String(updatedAtMs ?? Date.now())); +} + +/** Read the newest-wins stamp for a scope's OAuth copy (0 when absent). */ +export async function readProviderOAuthUpdatedAt( + get: (key: string) => Promise, + providerId: string, +): Promise { + const raw = (await get(providerOAuthVaultKeys(providerId).updatedAt))?.trim(); + const n = raw !== undefined && raw.length > 0 ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(n) ? n : 0; +} diff --git a/middleware/packages/llm-provider/src/providerFactory.ts b/middleware/packages/llm-provider/src/providerFactory.ts index a852f92a1..447f0cbb3 100644 --- a/middleware/packages/llm-provider/src/providerFactory.ts +++ b/middleware/packages/llm-provider/src/providerFactory.ts @@ -27,7 +27,11 @@ import type { import { defaultLlmAdapters } from './adapterRegistry.js'; import type { ProviderId } from './modelRegistry.js'; import type { LlmProviderCatalog } from './providerCatalog.js'; -import { readProviderApiKey } from './providerCredentials.js'; +import { readProviderApiKey, readProviderOAuthTokens } from './providerCredentials.js'; +import { + getProviderOAuthBearer, + type ProviderOAuthDeps, +} from './providerOAuthTokenStore.js'; /** * Default API base URLs for well-known OpenAI-compatible providers, so an @@ -65,6 +69,10 @@ export interface ResolveLlmProviderOptions { * `defaultLlmAdapters` (the app registers its bundled adapters into it at * boot); tests pass an isolated registry. */ readonly adapters?: LlmAdapterRegistry; + /** OAuth deps forwarded to the token store when the provider connects via + * OAuth (#294). Test seam — production omits it (global fetch + the real + * Codex config). */ + readonly oauth?: ProviderOAuthDeps; readonly log?: (...args: unknown[]) => void; } @@ -84,11 +92,18 @@ export async function resolveLlmProvider( const descriptor = opts.catalog?.get(opts.providerId); const apiKey = await readProviderApiKey(opts.getSecret, opts.providerId); + // OAuth-connected providers (#294 "Sign in with ChatGPT") carry no API key — + // "connected" means device-flow tokens are stored in this scope. The bearer + // itself is resolved per request via the process-wide token store (rotation- + // safe); here we only decide connectivity. + const oauthConnected = + descriptor?.oauth !== undefined && + (await readProviderOAuthTokens(opts.getSecret, opts.providerId)) !== undefined; // Local / self-hosted providers (e.g. Ollama) declare `policy.requiresApiKey: // 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; + if (apiKey === undefined && !keyless && !oauthConnected) return undefined; // 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 @@ -128,6 +143,13 @@ export async function resolveLlmProvider( ); } + // When the provider is OAuth-connected, hand the adapter a per-request bearer + // resolver instead of a static key. The closure captures only the providerId; + // the token (and its rotation) lives in the process-wide store. + const bearerProvider = oauthConnected + ? (): Promise => getProviderOAuthBearer(opts.providerId, opts.oauth) + : undefined; + // `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. @@ -137,6 +159,7 @@ export async function resolveLlmProvider( ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}), ...(opts.providerId !== 'openai' ? { id: opts.providerId } : {}), ...(descriptor?.quirks !== undefined ? { quirks: descriptor.quirks } : {}), + ...(bearerProvider !== undefined ? { bearerProvider } : {}), ...(opts.log !== undefined ? { log: opts.log } : {}), }); } diff --git a/middleware/packages/llm-provider/src/providerOAuthTokenStore.ts b/middleware/packages/llm-provider/src/providerOAuthTokenStore.ts new file mode 100644 index 000000000..8993b3825 --- /dev/null +++ b/middleware/packages/llm-provider/src/providerOAuthTokenStore.ts @@ -0,0 +1,196 @@ +/** + * Process-wide canonical store for provider OAuth tokens (phase 4b). + * + * WHY THIS EXISTS: auth.openai.com ROTATES refresh tokens (a rotated-away + * token later reused is a terminal `refresh_token_reused` error). The three + * LLM plugins (orchestrator / verifier / orchestrator-extras) each hold a + * scope-bound vault copy of the tokens; if each refreshed independently, the + * first rotation would kill the other two copies. So: + * + * - ONE in-memory canonical copy per providerId, shared by every caller in + * the process (the plugins are in-process packages). + * - ONE in-flight refresh at a time (single-flight promise) — a stampede is + * structurally impossible. + * - Rotated tokens are persisted through a boot-registered binding that fans + * them out to ALL vault scopes (plus an `oauth_updated_at` stamp) BEFORE + * the refresh promise resolves. + * - On restart, hydration reads every scope and takes the NEWEST copy + * (`oauth_updated_at`), so a crash between per-scope writes self-heals + * instead of resurrecting a pre-rotation refresh token. + * - A terminal refresh failure (grant dead) parks the provider in + * `reconnect_required` — no retry hammering; the operator re-runs the + * 30-second device flow, which re-primes the store. + */ +import { + isAccessTokenExpired, + OAuthReconnectRequiredError, + refreshAccessToken, + OPENAI_CODEX_OAUTH, + type FetchLike, + type NowMs, + type OAuthClientConfig, + type OAuthTokens, +} from './oauthDeviceFlow.js'; + +export interface ProviderOAuthStoreBinding { + /** Read every scope's copy; the store keeps the newest by `updatedAt`. */ + readonly load: () => Promise< + ReadonlyArray<{ tokens: OAuthTokens; updatedAt: number }> + >; + /** Fan the (possibly rotated) tokens out to every scope. */ + readonly persist: (tokens: OAuthTokens, updatedAtMs: number) => Promise; +} + +/** Injectable deps for {@link getProviderOAuthBearer} (test seam). */ +export interface ProviderOAuthDeps { + readonly fetchImpl?: FetchLike; + readonly config?: OAuthClientConfig; + readonly nowMs?: NowMs; + readonly log?: (...args: unknown[]) => void; +} + +interface StoreEntry { + tokens?: OAuthTokens; + /** In-flight (or settled) hydration — a PROMISE, not a bool, so a second + * concurrent caller awaits the same load instead of racing past a + * half-populated entry (the production binding spans many vault reads). */ + hydration?: Promise; + state: 'ok' | 'reconnect_required'; + inflight?: Promise; +} + +const entries = new Map(); +const bindings = new Map(); + +function entry(providerId: string): StoreEntry { + let e = entries.get(providerId); + if (!e) { + e = { state: 'ok' }; + entries.set(providerId, e); + } + return e; +} + +/** Boot-time registration (app layer owns non-scope-bound vault access). */ +export function registerProviderOAuthStoreBinding( + providerId: string, + binding: ProviderOAuthStoreBinding, +): void { + bindings.set(providerId, binding); + // A re-registration (e.g. plugin reactivation) must re-hydrate. + entries.delete(providerId); +} + +/** Prime the store right after a successful connect (poll route). Marks + * hydration as already done and clears any reconnect latch. */ +export function primeProviderOAuthTokens( + providerId: string, + tokens: OAuthTokens, +): void { + entries.set(providerId, { + tokens, + hydration: Promise.resolve(), + state: 'ok', + }); +} + +/** Whether the provider needs a fresh device-flow connect. */ +export function isProviderOAuthReconnectRequired(providerId: string): boolean { + return entries.get(providerId)?.state === 'reconnect_required'; +} + +function hydrate(providerId: string, e: StoreEntry): Promise { + if (e.hydration) return e.hydration; + const binding = bindings.get(providerId); + e.hydration = (async () => { + if (!binding) return; + const copies = await binding.load(); + let best: { tokens: OAuthTokens; updatedAt: number } | undefined; + for (const c of copies) { + if (!best || c.updatedAt > best.updatedAt) best = c; + } + // Only adopt hydrated tokens if a live connect hasn't primed newer ones. + if (best && e.tokens === undefined) e.tokens = best.tokens; + })(); + return e.hydration; +} + +/** + * Resolve a usable bearer for the provider, refreshing (and persisting the + * rotation) when the access token is expired. Throws + * {@link OAuthReconnectRequiredError} when the stored grant is dead and + * a plain Error when no tokens are stored at all. + */ +export async function getProviderOAuthBearer( + providerId: string, + deps: ProviderOAuthDeps = {}, +): Promise { + const e = entry(providerId); + if (e.state === 'reconnect_required') { + throw new OAuthReconnectRequiredError('cached'); + } + await hydrate(providerId, e); + const nowMs = deps.nowMs ?? Date.now; + if (e.tokens && !isAccessTokenExpired(e.tokens, nowMs())) { + return e.tokens.accessToken; + } + if (e.inflight) return e.inflight; + + const config = deps.config ?? OPENAI_CODEX_OAUTH; + const fetchImpl = + deps.fetchImpl ?? + (globalThis as unknown as { fetch?: FetchLike }).fetch; + if (fetchImpl === undefined) { + throw new Error('oauth: no fetch implementation available'); + } + + e.inflight = (async () => { + try { + const current = e.tokens; + if (current?.refreshToken === undefined) { + // No refresh path: hand out what we have (a 401 will surface truth), + // or fail clearly when nothing is stored. + if (current) return current.accessToken; + throw new Error( + `oauth: no tokens stored for provider "${providerId}" — connect it first`, + ); + } + const refreshed = await refreshAccessToken( + fetchImpl, + config, + current.refreshToken, + nowMs, + ); + // Memory is canonical (this store's whole design). Commit the rotation to + // memory FIRST — the old refresh token is already dead server-side, so the + // refreshed one must never be dropped. Then persist; a persist failure is + // degraded to a warning (worst case: lost on restart, recovered by a + // re-connect) rather than throwing away a live token and wedging the grant. + e.tokens = refreshed; + try { + await bindings.get(providerId)?.persist(refreshed, nowMs()); + } catch (persistErr) { + (deps.log ?? (() => undefined))( + `oauth: failed to persist rotated tokens for "${providerId}" (kept in memory):`, + persistErr, + ); + } + return refreshed.accessToken; + } catch (err) { + if (err instanceof OAuthReconnectRequiredError) { + e.state = 'reconnect_required'; + e.tokens = undefined; + } + throw err; + } finally { + e.inflight = undefined; + } + })(); + return e.inflight; +} + +/** Test seam: drop all in-memory state and bindings. */ +export function __resetProviderOAuthTokenStore(): void { + entries.clear(); + bindings.clear(); +} diff --git a/middleware/src/config.ts b/middleware/src/config.ts index 2f8e77ee0..cf4944095 100644 --- a/middleware/src/config.ts +++ b/middleware/src/config.ts @@ -613,6 +613,12 @@ const ConfigSchema = z.object({ // though the extracted Fly runner backend was its only reader. FLY_APP_NAME: optionalNonEmpty(z.string().min(1)), + // #294 — "Sign in with ChatGPT" (experimental). Registers the `openai-chatgpt` + // subscription provider (OAuth device flow → the ChatGPT/Codex Responses + // backend). OFF by default: it drives programmatic calls through a consumer + // ChatGPT subscription, a ToS grey area — the operator must opt in explicitly. + CHATGPT_SUBSCRIPTION_EXPERIMENTAL: devFlag(), + }); type ParsedConfig = z.infer; diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 8ffb05b0c..03788bd21 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -8,11 +8,17 @@ import { type AnthropicClient, } from '@omadia/llm-adapter-anthropic'; import { registerOpenAiAdapter } from '@omadia/llm-adapter-openai'; +import { registerOpenAiResponsesAdapter } from '@omadia/llm-adapter-openai-responses'; import { defaultLlmAdapters, LlmProviderCatalog, readProviderApiKey, + readProviderOAuthTokens, + readProviderOAuthUpdatedAt, + registerProviderOAuthStoreBinding, resolveLlmProvider, + writeProviderOAuthTokens, + type OAuthTokens, } from '@omadia/llm-provider'; import express from 'express'; import type { RequestHandler } from 'express'; @@ -518,6 +524,10 @@ async function main(): Promise { // another register*Adapter call here (or a plugin registering at activate). registerAnthropicAdapter(defaultLlmAdapters); registerOpenAiAdapter(defaultLlmAdapters); + // #294 — the OpenAI Responses (SSE) wire the ChatGPT/Codex subscription + // backend speaks (experimental "Sign in with ChatGPT"). Registered + // unconditionally (cheap, SDK-free); the PROVIDER that uses it is env-gated. + registerOpenAiResponsesAdapter(defaultLlmAdapters); // #309 Shape 2 — the local `claude` CLI as a keyless, tool-less completion // provider on the operator's subscription (not an HTTP wire format). registerClaudeCliAdapter(defaultLlmAdapters); @@ -532,7 +542,9 @@ async function main(): Promise { // overlay HERE — before plugin activation and before the builder/orchestrator // resolve a model — so a fresh install is functional out of the box. Installed // provider PLUGINS (e.g. MiniMax) register additionally, further below. - registerBuiltinLlmProviders(llmProviderCatalog); + registerBuiltinLlmProviders(llmProviderCatalog, { + includeExperimental: config.CHATGPT_SUBSCRIPTION_EXPERIMENTAL, + }); console.log( `[middleware] ${String(llmProviderCatalog.list().length)} built-in LLM provider(s) registered: ${llmProviderCatalog .list() @@ -4147,6 +4159,51 @@ async function main(): Promise { ); console.log('[middleware] providers admin endpoint ready at /api/v1/admin/providers (auth: required)'); + // #294 — bind the process-wide OAuth token store for the ChatGPT provider to + // the vault: `load` reads every LLM-plugin scope newest-wins (rotation makes + // divergent copies dangerous), `persist` fans a refreshed/rotated token back + // out to all scopes with one shared stamp. Only wired when the experimental + // provider is on; harmless otherwise (no provider resolves the bearer). + if (config.CHATGPT_SUBSCRIPTION_EXPERIMENTAL) { + const oauthVault = secretVault; + const OAUTH_PROVIDER = 'openai-chatgpt'; + const LLM_SCOPES = [ + '@omadia/orchestrator', + '@omadia/verifier', + '@omadia/orchestrator-extras', + ] as const; + registerProviderOAuthStoreBinding(OAUTH_PROVIDER, { + load: async () => { + const copies: Array<{ tokens: OAuthTokens; updatedAt: number }> = []; + for (const scope of LLM_SCOPES) { + const tokens = await readProviderOAuthTokens( + (k) => oauthVault.get(scope, k), + OAUTH_PROVIDER, + ); + if (tokens !== undefined) { + const updatedAt = await readProviderOAuthUpdatedAt( + (k) => oauthVault.get(scope, k), + OAUTH_PROVIDER, + ); + copies.push({ tokens, updatedAt }); + } + } + return copies; + }, + persist: async (tokens, updatedAtMs) => { + for (const scope of LLM_SCOPES) { + await writeProviderOAuthTokens( + (k, v) => oauthVault.setMany(scope, { [k]: v }), + OAUTH_PROVIDER, + tokens, + updatedAtMs, + ); + } + }, + }); + console.log('[middleware] ChatGPT-subscription OAuth token store bound (experimental)'); + } + // Embedding-provider switch (#440 follow-up) — pick which `embeddingClient@1` // adapter is active, LIVE. Unlike the memory-backend router next door this // one does not persist-and-ask-for-a-restart: it deactivates the outgoing diff --git a/middleware/src/platform/builtinLlmProviders.ts b/middleware/src/platform/builtinLlmProviders.ts index 8291753ec..e5b8881d3 100644 --- a/middleware/src/platform/builtinLlmProviders.ts +++ b/middleware/src/platform/builtinLlmProviders.ts @@ -224,13 +224,77 @@ export const BUILTIN_LLM_PROVIDERS: ReadonlyArray = [ }, ]; +/** + * Experimental providers, kept OUT of {@link BUILTIN_LLM_PROVIDERS} so the + * stable set (and everything that flattens it) is unaffected. Registered only + * behind an explicit opt-in. + * + * `openai-chatgpt` (#294 "Sign in with ChatGPT"): connects via an OAuth device + * flow instead of an API key; the resulting subscription bearer is scoped to + * the ChatGPT/Codex Responses backend (verified live), NOT the public + * api.openai.com surface — hence the dedicated `openai-responses` wire format + * and its own baseURL. + */ +export const EXPERIMENTAL_LLM_PROVIDERS: ReadonlyArray = [ + { + id: 'openai-chatgpt', + label: 'ChatGPT (subscription)', + wireFormat: 'openai-responses', + baseURL: 'https://chatgpt.com/backend-api/codex', + oauth: { kind: 'device' }, + // No API key — the login IS the credential. Like `claude-cli`, a consumer + // subscription has no data-processing agreement, so `subscriptionNotice` + // surfaces the stronger caveat (and the connect modal shows the ToS notice). + policy: { + requiresApiKey: false, + requiresAvvDisclosure: false, + subscriptionNotice: true, + }, + models: [ + { + id: 'openai-chatgpt:gpt-5.5', + provider: 'openai-chatgpt', + modelId: 'gpt-5.5', + label: 'GPT-5.5 (ChatGPT)', + class: 'frontier', + maxTokens: 128_000, + contextWindow: 400_000, + vision: true, + }, + { + id: 'openai-chatgpt:gpt-5.4', + provider: 'openai-chatgpt', + modelId: 'gpt-5.4', + label: 'GPT-5.4 (ChatGPT)', + class: 'balanced', + maxTokens: 128_000, + contextWindow: 400_000, + vision: true, + classDefault: true, + }, + ], + }, +]; + /** * Register the bundled built-in providers into a catalog (which also registers * their models into the global overlay). Idempotent per catalog. Call at boot * before plugin activation, and in tests that need a populated model registry. + * + * `includeExperimental` (default false) additionally registers + * {@link EXPERIMENTAL_LLM_PROVIDERS} — the `openai-chatgpt` "Sign in with + * ChatGPT" backend (#294) — gated on `CHATGPT_SUBSCRIPTION_EXPERIMENTAL`. */ -export function registerBuiltinLlmProviders(catalog: LlmProviderCatalog): void { +export function registerBuiltinLlmProviders( + catalog: LlmProviderCatalog, + opts: { includeExperimental?: boolean } = {}, +): void { for (const descriptor of BUILTIN_LLM_PROVIDERS) { catalog.register(descriptor); } + if (opts.includeExperimental === true) { + for (const descriptor of EXPERIMENTAL_LLM_PROVIDERS) { + catalog.register(descriptor); + } + } } diff --git a/middleware/src/routes/adminProviders.ts b/middleware/src/routes/adminProviders.ts index d960cefa1..e59afb5fe 100644 --- a/middleware/src/routes/adminProviders.ts +++ b/middleware/src/routes/adminProviders.ts @@ -29,13 +29,26 @@ * dependent on the provider being up. */ import { + exchangeAuthorizationCode, + isProviderOAuthReconnectRequired, legacyProviderApiKeyVaultKey, listModels, listModelsByProvider, + OPENAI_CODEX_OAUTH, + pollDeviceToken, + primeProviderOAuthTokens, providerApiKeyVaultKey, + readProviderOAuthTokens, + requestUserCode, resolveModelRef, + writeProviderOAuthTokens, + type OAuthClientConfig, + type OAuthTokens, type ProviderId, + type UserCodeGrant, } from '@omadia/llm-provider'; +import { randomUUID } from 'node:crypto'; + import { Router } from 'express'; import type { Request, Response } from 'express'; @@ -72,9 +85,15 @@ export interface AdminProvidersDeps { readonly requiresApiKey?: boolean; readonly subscriptionNotice?: boolean; }; + readonly oauth?: { readonly kind: 'device' }; } | undefined; }; + /** OAuth client config for the device flow. Defaults to the OpenAI Codex + * client; a test injects a fake pointing at a mock issuer. */ + readonly oauthConfig?: OAuthClientConfig; + /** Injected fetch for the device-flow HTTP calls (test seam). */ + readonly oauthFetch?: typeof fetch; } /** The subset of a catalog descriptor the credential probe needs. */ @@ -172,6 +191,52 @@ async function findProviderKey( return undefined; } +/** True when any LLM-plugin scope holds an OAuth access token for the provider + * (#294). "Connected via Sign in with ChatGPT" is exactly this. */ +async function isProviderOAuthConnected( + vault: SecretVault | undefined, + provider: ProviderId, +): Promise { + if (!vault) return false; + for (const desc of LLM_PLUGINS) { + const tokens = await readProviderOAuthTokens( + (k) => vault.get(desc.id, k), + provider, + ); + if (tokens !== undefined) return true; + } + return false; +} + +/** Fan a provider's OAuth tokens out to EVERY LLM-plugin scope with one shared + * `updatedAt` stamp (newest-wins hydration relies on the stamp), then reactivate + * the installed ones so they re-read. Refresh-token rotation makes divergent + * per-scope copies dangerous — this keeps them identical. */ +async function fanOutProviderOAuthTokens( + deps: AdminProvidersDeps, + provider: ProviderId, + tokens: OAuthTokens, +): Promise { + const vault = deps.vault; + if (!vault) return; + const updatedAt = Date.now(); + for (const desc of LLM_PLUGINS) { + await writeProviderOAuthTokens( + (k, v) => vault.setMany(desc.id, { [k]: v }), + provider, + tokens, + updatedAt, + ); + } + if (deps.reactivate) { + for (const desc of LLM_PLUGINS) { + if (deps.installedRegistry.has(desc.id)) { + await deps.reactivate(desc.id).catch(() => undefined); + } + } + } +} + /** * The provider's credential verdict, WITHOUT touching the network: * - no key in any scope → `no_key` @@ -271,6 +336,9 @@ export function createAdminProvidersRouter(deps: AdminProvidersDeps): Router { const providerRows = await Promise.all( providerIds.map(async (id) => { const descriptor = deps.llmProviderCatalog?.get(id); + // #294: an OAuth provider is "connected" when device-flow tokens are + // stored — the login IS the credential, so there is no key to probe. + const oauthConnect = descriptor?.oauth !== undefined; // #309: a CLI-backed provider is keyless — its "does it work" probe is // the CLI login check above, not a credential probe. const verification: ProviderVerification = @@ -278,7 +346,16 @@ export function createAdminProvidersRouter(deps: AdminProvidersDeps): Router { ? cliConnected('claude') ? { status: 'verified' } : { status: 'no_key' } - : await resolveStatus(deps.vault, id, descriptor); + : oauthConnect + ? // A dead grant (terminal refresh failure) leaves stale tokens + // in the vault; the process-wide latch is the truth. Report + // `no_key` so the row shows "Reconnect" instead of a green + // chip that lies while every call throws. + isProviderOAuthReconnectRequired(id) || + !(await isProviderOAuthConnected(deps.vault, id)) + ? { status: 'no_key' } + : { status: 'verified' } + : await resolveStatus(deps.vault, id, descriptor); return { id, label: descriptor?.label ?? providerLabel(id), @@ -331,6 +408,9 @@ export function createAdminProvidersRouter(deps: AdminProvidersDeps): Router { // exist for them, which the operator must know BEFORE routing // personal data through such an agent (field-test OM-10 family). subscriptionNotice: descriptor?.policy?.subscriptionNotice ?? false, + // #294: the provider connects via an OAuth device flow, so the UI + // renders a "Sign in with ChatGPT" button instead of a key field. + oauthConnect, models: listModelsByProvider(id).map((m) => ({ id: m.id, modelId: m.modelId, @@ -545,5 +625,123 @@ export function createAdminProvidersRouter(deps: AdminProvidersDeps): Router { res.json({ ok: true, pluginId, provider, model: storeModel }); }); + // ------------------------------------------------------------------------- + // #294 — "Sign in with ChatGPT" device-flow connect. The device-auth id + + // user code (the poll secret) never leave the server; the browser only ever + // holds a random flowId. Single-operator, single-process → an in-memory map + // mirrors the plugin OAuth broker's pendingFlows pattern. + // ------------------------------------------------------------------------- + interface PendingFlow { + readonly providerId: ProviderId; + readonly grant: UserCodeGrant; + readonly deadlineMs: number; + } + const pendingFlows = new Map(); + const FLOW_CAP_MS = 15 * 60_000; + const MAX_PENDING_FLOWS = 32; + const oauthConfig = deps.oauthConfig ?? OPENAI_CODEX_OAUTH; + + /** Drop expired flows so an operator closing the modal (or StrictMode's + * double-mount) can't leak entries forever. */ + const sweepExpiredFlows = (): void => { + const now = Date.now(); + for (const [id, flow] of pendingFlows) { + if (now > flow.deadlineMs) pendingFlows.delete(id); + } + }; + + const resolveOAuthProvider = (raw: unknown): ProviderId | undefined => { + const id = typeof raw === 'string' && raw.length > 0 ? raw : 'openai-chatgpt'; + return deps.llmProviderCatalog?.get(id)?.oauth !== undefined + ? (id as ProviderId) + : undefined; + }; + + router.post('/oauth/start', async (req: Request, res: Response) => { + const providerId = resolveOAuthProvider( + (req.body as { provider?: unknown } | null)?.provider, + ); + if (providerId === undefined) { + res.status(400).json({ + code: 'providers.oauth_unsupported', + message: 'This provider does not support OAuth device login.', + }); + return; + } + sweepExpiredFlows(); + if (pendingFlows.size >= MAX_PENDING_FLOWS) { + res.status(429).json({ + code: 'providers.oauth_too_many_flows', + message: 'Too many login attempts in flight. Try again shortly.', + }); + return; + } + try { + const fetchImpl = deps.oauthFetch ?? fetch; + const grant = await requestUserCode( + (url, init) => fetchImpl(url, init), + oauthConfig, + ); + const flowId = randomUUID(); + pendingFlows.set(flowId, { + providerId, + grant, + deadlineMs: Date.now() + FLOW_CAP_MS, + }); + res.json({ + flowId, + userCode: grant.userCode, + verificationUri: grant.verificationUri, + interval: grant.interval, + }); + } catch (err) { + res.status(502).json({ + code: 'providers.oauth_start_failed', + message: err instanceof Error ? err.message : String(err), + }); + } + }); + + router.post('/oauth/poll', async (req: Request, res: Response) => { + const flowId = (req.body as { flowId?: unknown } | null)?.flowId; + const flow = typeof flowId === 'string' ? pendingFlows.get(flowId) : undefined; + if (flow === undefined) { + res.status(404).json({ status: 'expired' }); + return; + } + if (Date.now() > flow.deadlineMs) { + pendingFlows.delete(flowId as string); + res.json({ status: 'expired' }); + return; + } + try { + const fetchImpl = deps.oauthFetch ?? fetch; + const wrapped = (url: string, init: Parameters[1]) => + fetchImpl(url, init); + const poll = await pollDeviceToken(wrapped, oauthConfig, flow.grant); + if (poll.status === 'pending') { + res.json({ status: 'pending' }); + return; + } + // Approved → exchange for tokens, fan out to every scope, prime the store. + const tokens = await exchangeAuthorizationCode( + wrapped, + oauthConfig, + { authorizationCode: poll.authorizationCode, codeVerifier: poll.codeVerifier }, + Date.now, + ); + await fanOutProviderOAuthTokens(deps, flow.providerId, tokens); + primeProviderOAuthTokens(flow.providerId, tokens); + pendingFlows.delete(flowId as string); + res.json({ status: 'complete' }); + } catch (err) { + res.status(502).json({ + status: 'error', + code: 'providers.oauth_poll_failed', + message: err instanceof Error ? err.message : String(err), + }); + } + }); + return router; } diff --git a/middleware/test/adminProvidersOAuthRoutes.test.ts b/middleware/test/adminProvidersOAuthRoutes.test.ts new file mode 100644 index 000000000..07d79ac9e --- /dev/null +++ b/middleware/test/adminProvidersOAuthRoutes.test.ts @@ -0,0 +1,214 @@ +/** + * /api/v1/admin/providers OAuth connect routes (#294) — device-flow start/poll, + * token fan-out to every LLM scope, reactivation, and the `oauthConnect`/status + * surfacing on the listing. Injected fetch + config: no live endpoints. + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { + LlmProviderCatalog, + clearExternalModels, + providerOAuthVaultKeys, + type OAuthClientConfig, + __resetProviderOAuthTokenStore, +} from '@omadia/llm-provider'; +import express from 'express'; +import type { Express } from 'express'; + +import { createAdminProvidersRouter } from '../src/routes/adminProviders.js'; +import { InMemoryInstalledRegistry } from '../src/plugins/installedRegistry.js'; +import { InMemorySecretVault } from '../src/secrets/vault.js'; +import { registerBuiltinLlmProviders } from '../src/platform/builtinLlmProviders.js'; + +const ORCH = '@omadia/orchestrator'; +const VERIFIER = '@omadia/verifier'; +const EXTRAS = '@omadia/orchestrator-extras'; +const PROVIDER = 'openai-chatgpt'; +const CFG: OAuthClientConfig = { + issuer: 'https://issuer.test', + clientId: 'cid', + userAgent: 'ua', +}; + +interface Step { + ok?: boolean; + status?: number; + body: unknown; +} + +/** A scripted fetch: each outbound call consumes the next step in the queue. */ +function scriptedFetch(steps: Step[]): { fetch: typeof globalThis.fetch; remaining: () => number } { + const queue = [...steps]; + const fetch: typeof globalThis.fetch = (async () => { + const step = queue.shift() ?? { body: {} }; + return { + ok: step.ok ?? (step.status ?? 200) < 400, + status: step.status ?? 200, + json: async () => step.body, + text: async () => JSON.stringify(step.body), + } as unknown as Response; + }) as unknown as typeof fetch; + return { fetch, remaining: () => queue.length }; +} + +async function makeHarness(steps: Step[]) { + clearExternalModels(); + __resetProviderOAuthTokenStore(); + const vault = new InMemorySecretVault(); + const registry = new InMemoryInstalledRegistry(); + for (const id of [ORCH, VERIFIER, EXTRAS]) { + await registry.register({ + id, + installed_version: '0.1.0', + installed_at: new Date().toISOString(), + status: 'active', + config: {}, + }); + } + const catalog = new LlmProviderCatalog(); + registerBuiltinLlmProviders(catalog, { includeExperimental: true }); + const reactivated: string[] = []; + const { fetch } = scriptedFetch(steps); + const app: Express = express(); + app.use(express.json()); + app.use( + '/api/v1/admin/providers', + createAdminProvidersRouter({ + installedRegistry: registry, + vault, + reactivate: async (id) => { + reactivated.push(id); + }, + llmProviderCatalog: catalog, + oauthConfig: CFG, + oauthFetch: fetch, + }), + ); + const server: Server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + const port = (server.address() as AddressInfo).port; + return { + baseUrl: `http://127.0.0.1:${String(port)}`, + vault, + reactivated, + async close() { + await new Promise((r) => server.close(() => r())); + }, + }; +} + +let harness: Awaited> | undefined; +afterEach(async () => { + await harness?.close(); + harness = undefined; + __resetProviderOAuthTokenStore(); +}); + +describe('POST /oauth/start', () => { + it('returns a user code + verification URL and never leaks the poll secret', async () => { + harness = await makeHarness([ + { body: { device_auth_id: 'dev-1', user_code: 'WXYZ-9', interval: 5 } }, + ]); + const res = await fetch(`${harness.baseUrl}/api/v1/admin/providers/oauth/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: PROVIDER }), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.equal(body['userCode'], 'WXYZ-9'); + assert.equal(body['verificationUri'], 'https://issuer.test/codex/device'); + assert.ok(typeof body['flowId'] === 'string' && body['flowId'].length > 0); + // The device_auth_id (poll secret) must not be in the response. + assert.equal(JSON.stringify(body).includes('dev-1'), false); + }); + + it('rejects a provider that does not declare OAuth', async () => { + harness = await makeHarness([]); + const res = await fetch(`${harness.baseUrl}/api/v1/admin/providers/oauth/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: 'anthropic' }), + }); + assert.equal(res.status, 400); + }); +}); + +describe('POST /oauth/poll', () => { + async function startFlow(): Promise { + const res = await fetch(`${harness!.baseUrl}/api/v1/admin/providers/oauth/start`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: PROVIDER }), + }); + return ((await res.json()) as { flowId: string }).flowId; + } + + it('surfaces pending, then on approval writes tokens to ALL scopes + reactivates', async () => { + harness = await makeHarness([ + // start → usercode + { body: { device_auth_id: 'dev-1', user_code: 'U', interval: 5 } }, + // poll #1 → not approved yet (non-2xx) + { status: 400, body: {} }, + // poll #2 → approved (authorization code + PKCE) + { body: { authorization_code: 'auth', code_challenge: 'c', code_verifier: 'v' } }, + // token exchange → tokens + { body: { access_token: 'at', refresh_token: 'rt', expires_in: 3600 } }, + ]); + const flowId = await startFlow(); + + const pending = await fetch(`${harness.baseUrl}/api/v1/admin/providers/oauth/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ flowId }), + }); + assert.deepEqual(await pending.json(), { status: 'pending' }); + + const done = await fetch(`${harness.baseUrl}/api/v1/admin/providers/oauth/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ flowId }), + }); + assert.deepEqual(await done.json(), { status: 'complete' }); + + // Every LLM scope holds the access token (identical-copies invariant). + const keys = providerOAuthVaultKeys(PROVIDER); + for (const scope of [ORCH, VERIFIER, EXTRAS]) { + assert.equal(await harness.vault.get(scope, keys.access), 'at'); + assert.equal(await harness.vault.get(scope, keys.refresh), 'rt'); + assert.ok(await harness.vault.get(scope, keys.updatedAt)); + } + // All three installed plugins reactivated. + assert.deepEqual([...harness.reactivated].sort(), [EXTRAS, ORCH, VERIFIER].sort()); + }); + + it('a stale/unknown flowId is expired', async () => { + harness = await makeHarness([]); + const res = await fetch(`${harness.baseUrl}/api/v1/admin/providers/oauth/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ flowId: 'nope' }), + }); + assert.equal(res.status, 404); + assert.deepEqual(await res.json(), { status: 'expired' }); + }); +}); + +describe('GET / (listing)', () => { + it('marks the ChatGPT provider oauthConnect and no_key until connected', async () => { + harness = await makeHarness([]); + const res = await fetch(`${harness.baseUrl}/api/v1/admin/providers`); + const body = (await res.json()) as { + providers: Array<{ id: string; oauthConnect?: boolean; status: string; subscriptionNotice?: boolean }>; + }; + const row = body.providers.find((p) => p.id === PROVIDER); + assert.ok(row, 'openai-chatgpt should be listed when experimental is on'); + assert.equal(row.oauthConnect, true); + assert.equal(row.status, 'no_key'); + assert.equal(row.subscriptionNotice, true); + }); +}); diff --git a/middleware/test/llmAdapterOpenAiResponses.test.ts b/middleware/test/llmAdapterOpenAiResponses.test.ts new file mode 100644 index 000000000..780207e1d --- /dev/null +++ b/middleware/test/llmAdapterOpenAiResponses.test.ts @@ -0,0 +1,197 @@ +/** + * OpenAI Responses (SSE) adapter (#294) — SSE parsing + stream/complete mapping, + * tool calls, header/body invariants, error classification. No live network: an + * injected fetch returns a canned SSE ReadableStream. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + createOpenAiResponsesProvider, + ResponsesHttpError, + SseParser, +} from '../packages/llm-adapter-openai-responses/dist/index.js'; +import type { LlmRequest } from '@omadia/llm-provider-api'; + +/** Build a fetch double that streams the given raw SSE text, chunked at `every` + * characters to exercise the parser's cross-chunk frame handling. */ +function sseFetch( + raw: string, + opts: { status?: number; every?: number; captured?: { init?: RequestInit } } = {}, +): typeof fetch { + const status = opts.status ?? 200; + const every = opts.every ?? 7; + return (async (_url: string, init: RequestInit) => { + if (opts.captured) opts.captured.init = init; + if (status >= 400) { + return { + ok: false, + status, + body: {}, + text: async () => 'boom', + } as unknown as Response; + } + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < raw.length; i += every) { + controller.enqueue(encoder.encode(raw.slice(i, i + every))); + } + controller.close(); + }, + }); + return { ok: true, status, body: stream } as unknown as Response; + }) as unknown as typeof fetch; +} + +const REQ: LlmRequest = { + model: 'gpt-5.4', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + maxTokens: 64, +}; + +describe('SseParser', () => { + it('reassembles events split across chunks', () => { + const p = new SseParser(); + const events = [ + ...p.push('event: a\ndata: {"x":'), + ...p.push('1}\n\nevent: b\nda'), + ...p.push('ta: hello\n\n'), + ]; + assert.deepEqual(events, [ + { event: 'a', data: '{"x":1}' }, + { event: 'b', data: 'hello' }, + ]); + }); +}); + +describe('openai-responses provider — text', () => { + it('streams text deltas and aggregates a final response with usage', async () => { + const raw = + 'event: response.created\ndata: {}\n\n' + + 'event: response.output_text.delta\ndata: {"delta":"Hel"}\n\n' + + 'event: response.output_text.delta\ndata: {"delta":"lo"}\n\n' + + 'event: response.completed\ndata: {"response":{"status":"completed","model":"gpt-5.4","usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}]}}\n\n'; + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + apiKey: 'tok', + fetchImpl: sseFetch(raw), + }); + + const deltas: string[] = []; + let final; + for await (const evt of provider.stream(REQ)) { + if (evt.type === 'text_delta') deltas.push(evt.text); + if (evt.type === 'final') final = evt.response; + } + assert.deepEqual(deltas, ['Hel', 'lo']); + assert.equal(final?.finishReason, 'stop'); + assert.equal(final?.content[0]?.type, 'text'); + assert.equal(final?.usage.inputTokens, 3); + assert.equal(final?.usage.outputTokens, 2); + + const completed = await provider.complete(REQ); + assert.equal(completed.model, 'gpt-5.4'); + }); +}); + +describe('openai-responses provider — tools', () => { + it('maps a function call to a tool_call with parsed input and tool_calls finish', async () => { + const raw = + 'event: response.output_item.added\ndata: {"item":{"type":"function_call"}}\n\n' + + 'event: response.function_call_arguments.delta\ndata: {"delta":"{\\"city\\":"}\n\n' + + 'event: response.completed\ndata: {"response":{"status":"completed","output":[{"type":"function_call","call_id":"c1","name":"get_weather","arguments":"{\\"city\\":\\"Berlin\\"}"}]}}\n\n'; + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + apiKey: 'tok', + fetchImpl: sseFetch(raw), + }); + let sawToolStart = false; + let final; + for await (const evt of provider.stream(REQ)) { + if (evt.type === 'tool_use_start') sawToolStart = true; + if (evt.type === 'final') final = evt.response; + } + assert.equal(sawToolStart, true); + assert.equal(final?.finishReason, 'tool_calls'); + const call = final?.content.find((c) => c.type === 'tool_call'); + assert.ok(call && call.type === 'tool_call'); + assert.equal(call.name, 'get_weather'); + assert.deepEqual(call.input, { city: 'Berlin' }); + }); +}); + +describe('openai-responses provider — request invariants', () => { + it('forces stream+store and sends the experimental headers + bearer', async () => { + const captured: { init?: RequestInit } = {}; + const raw = + 'event: response.completed\ndata: {"response":{"status":"completed","output":[]}}\n\n'; + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + bearerProvider: async () => 'live-bearer', + fetchImpl: sseFetch(raw, { captured }), + }); + await provider.complete({ + ...REQ, + system: 'be terse', + tools: [ + { + name: 'get_weather', + description: 'w', + inputSchema: { type: 'object', properties: {} }, + }, + ], + toolChoice: { type: 'tool', name: 'get_weather' }, + }); + const headers = captured.init?.headers as Record; + assert.equal(headers['authorization'], 'Bearer live-bearer'); + assert.equal(headers['openai-beta'], 'responses=experimental'); + assert.ok(headers['session_id']); + const body = JSON.parse(captured.init?.body as string); + assert.equal(body.stream, true); + assert.equal(body.store, false); + assert.equal(body.instructions, 'be terse'); + assert.deepEqual(body.tool_choice, { type: 'function', name: 'get_weather' }); + assert.equal(body.tools[0].name, 'get_weather'); + }); +}); + +describe('openai-responses provider — errors', () => { + it('throws ResponsesHttpError and classifies 401 as non-retryable auth', async () => { + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + apiKey: 'tok', + fetchImpl: sseFetch('', { status: 401 }), + }); + await assert.rejects(() => provider.complete(REQ), ResponsesHttpError); + assert.deepEqual(provider.classifyError(new ResponsesHttpError(401, '')), { + retryable: false, + kind: 'auth', + }); + assert.deepEqual(provider.classifyError(new ResponsesHttpError(429, '')), { + retryable: true, + kind: 'rate_limit', + }); + }); + + it('classifies a dead OAuth grant (by error name) as non-retryable auth', async () => { + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + apiKey: 'tok', + fetchImpl: sseFetch(''), + }); + // Mirror the cross-package error the bearerProvider throws (name-matched). + const err = new Error('grant dead'); + err.name = 'OAuthReconnectRequiredError'; + assert.deepEqual(provider.classifyError(err), { retryable: false, kind: 'auth' }); + }); + + it('throws when the stream ends without response.completed', async () => { + const provider = createOpenAiResponsesProvider({ + baseURL: 'https://x/codex', + apiKey: 'tok', + fetchImpl: sseFetch('event: response.created\ndata: {}\n\n'), + }); + await assert.rejects(() => provider.complete(REQ), /without response.completed/); + }); +}); diff --git a/middleware/test/llmProviderOAuthDeviceFlow.test.ts b/middleware/test/llmProviderOAuthDeviceFlow.test.ts new file mode 100644 index 000000000..a9f58f69c --- /dev/null +++ b/middleware/test/llmProviderOAuthDeviceFlow.test.ts @@ -0,0 +1,223 @@ +/** + * "Sign in with ChatGPT" device-flow token lifecycle (#294) — unit tests for + * the REAL 3-step protocol (verified live 2026-08-21 against auth.openai.com + + * the open-source codex CLI). No live network: every call takes an injected + * fetch + clock. + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + OAuthReconnectRequiredError, + OPENAI_CODEX_OAUTH, + exchangeAuthorizationCode, + isAccessTokenExpired, + jwtExpiryMs, + pollDeviceToken, + refreshAccessToken, + requestUserCode, + type FetchLike, +} from '@omadia/llm-provider'; + +/** A programmable fetch double that records the last request. */ +function fakeFetch( + handler: (url: string, init: Parameters[1]) => { + ok?: boolean; + status?: number; + body: unknown; + }, +): { fetch: FetchLike; calls: Array<{ url: string; init: Parameters[1] }> } { + const calls: Array<{ url: string; init: Parameters[1] }> = []; + const fetch: FetchLike = async (url, init) => { + calls.push({ url, init }); + const r = handler(url, init); + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: async () => r.body, + text: async () => JSON.stringify(r.body), + } as Awaited>; + }; + return { fetch, calls }; +} + +const CFG = OPENAI_CODEX_OAUTH; + +describe('requestUserCode (step 1)', () => { + it('POSTs JSON {client_id} to the usercode endpoint with a UA header', async () => { + const { fetch, calls } = fakeFetch(() => ({ + body: { + device_auth_id: 'dev-1', + user_code: 'ABCD-1234', + interval: 5, + expires_at: '2026-08-21T08:00:00Z', + }, + })); + const grant = await requestUserCode(fetch, CFG); + assert.equal(calls[0]?.url, 'https://auth.openai.com/api/accounts/deviceauth/usercode'); + assert.equal(calls[0]?.init.method, 'POST'); + assert.equal(calls[0]?.init.headers['content-type'], 'application/json'); + assert.ok(calls[0]?.init.headers['user-agent']); // Cloudflare needs a real UA + assert.deepEqual(JSON.parse(calls[0]!.init.body), { client_id: CFG.clientId }); + assert.equal(grant.deviceAuthId, 'dev-1'); + assert.equal(grant.userCode, 'ABCD-1234'); + assert.equal(grant.verificationUri, 'https://auth.openai.com/codex/device'); + assert.equal(grant.interval, 5); + assert.equal(grant.expiresAtMs, Date.parse('2026-08-21T08:00:00Z')); + }); + + it('accepts the `usercode` alias and defaults a missing interval', async () => { + const { fetch } = fakeFetch(() => ({ + body: { device_auth_id: 'd', usercode: 'X' }, + })); + const grant = await requestUserCode(fetch, CFG); + assert.equal(grant.userCode, 'X'); + assert.equal(grant.interval, 5); + }); + + it('throws on a non-2xx', async () => { + const { fetch } = fakeFetch(() => ({ ok: false, status: 429, body: {} })); + await assert.rejects(() => requestUserCode(fetch, CFG), /user-code request failed \(429\)/); + }); +}); + +describe('pollDeviceToken (step 2)', () => { + it('returns pending on any non-2xx (approval not granted yet)', async () => { + const { fetch } = fakeFetch(() => ({ ok: false, status: 400, body: {} })); + const r = await pollDeviceToken(fetch, CFG, { deviceAuthId: 'd', userCode: 'u' }); + assert.deepEqual(r, { status: 'pending' }); + }); + + it('returns the server-generated PKCE material on approval', async () => { + const { fetch, calls } = fakeFetch(() => ({ + body: { + authorization_code: 'auth-xyz', + code_challenge: 'chal', + code_verifier: 'verif', + }, + })); + const r = await pollDeviceToken(fetch, CFG, { deviceAuthId: 'd', userCode: 'u' }); + assert.equal(calls[0]?.url, 'https://auth.openai.com/api/accounts/deviceauth/token'); + assert.deepEqual(JSON.parse(calls[0]!.init.body), { + device_auth_id: 'd', + user_code: 'u', + }); + assert.equal(r.status, 'complete'); + if (r.status === 'complete') { + assert.equal(r.authorizationCode, 'auth-xyz'); + assert.equal(r.codeVerifier, 'verif'); + } + }); +}); + +describe('exchangeAuthorizationCode (step 3)', () => { + it('form-encodes the authorization-code grant with the device callback URI', async () => { + const { fetch, calls } = fakeFetch(() => ({ + body: { + access_token: 'at-1', + refresh_token: 'rt-1', + id_token: 'id-1', + expires_in: 3600, + }, + })); + const tokens = await exchangeAuthorizationCode( + fetch, + CFG, + { authorizationCode: 'auth-xyz', codeVerifier: 'verif' }, + () => 1_000_000, + ); + assert.equal(calls[0]?.url, 'https://auth.openai.com/oauth/token'); + assert.equal( + calls[0]?.init.headers['content-type'], + 'application/x-www-form-urlencoded', + ); + const body = calls[0]!.init.body; + assert.match(body, /grant_type=authorization_code/); + assert.match(body, /code=auth-xyz/); + assert.match(body, /redirect_uri=https%3A%2F%2Fauth.openai.com%2Fdeviceauth%2Fcallback/); + assert.match(body, /code_verifier=verif/); + assert.equal(tokens.accessToken, 'at-1'); + assert.equal(tokens.refreshToken, 'rt-1'); + assert.equal(tokens.expiresAt, 1_000_000 + 3600 * 1000); + }); +}); + +describe('refreshAccessToken', () => { + it('sends a JSON refresh body and adopts a ROTATED refresh token', async () => { + const { fetch, calls } = fakeFetch(() => ({ + body: { access_token: 'at-2', refresh_token: 'rt-2', expires_in: 3600 }, + })); + const tokens = await refreshAccessToken(fetch, CFG, 'rt-1', () => 5_000); + assert.equal(calls[0]?.init.headers['content-type'], 'application/json'); + assert.deepEqual(JSON.parse(calls[0]!.init.body), { + client_id: CFG.clientId, + grant_type: 'refresh_token', + refresh_token: 'rt-1', + }); + assert.equal(tokens.accessToken, 'at-2'); + assert.equal(tokens.refreshToken, 'rt-2'); // rotated → new one adopted + }); + + it('keeps the old refresh token when the server does not rotate it', async () => { + const { fetch } = fakeFetch(() => ({ + body: { access_token: 'at-2', expires_in: 3600 }, + })); + const tokens = await refreshAccessToken(fetch, CFG, 'rt-1', () => 0); + assert.equal(tokens.refreshToken, 'rt-1'); + }); + + it('throws OAuthReconnectRequiredError on a terminal reuse error', async () => { + const { fetch } = fakeFetch(() => ({ + ok: false, + status: 400, + body: { error: { code: 'refresh_token_reused' } }, + })); + await assert.rejects( + () => refreshAccessToken(fetch, CFG, 'rt-dead', () => 0), + OAuthReconnectRequiredError, + ); + }); + + it('throws a plain error on a transient non-terminal failure', async () => { + const { fetch } = fakeFetch(() => ({ + ok: false, + status: 503, + body: { error: 'temporarily_unavailable' }, + })); + await assert.rejects( + () => refreshAccessToken(fetch, CFG, 'rt-1', () => 0), + /token refresh failed \(503: temporarily_unavailable\)/, + ); + }); +}); + +describe('token expiry helpers', () => { + it('derives expiry from a JWT exp claim when expires_in is absent', () => { + // { "exp": 2000 } → 2000 * 1000 ms + const payload = Buffer.from(JSON.stringify({ exp: 2000 })).toString('base64url'); + const jwt = `h.${payload}.sig`; + assert.equal(jwtExpiryMs(jwt), 2_000_000); + }); + + it('isAccessTokenExpired respects the skew and the no-expiry case', () => { + assert.equal(isAccessTokenExpired(undefined, 0), true); + assert.equal( + isAccessTokenExpired({ accessToken: 'a' }, 999_999_999), // no expiresAt → valid + false, + ); + // now well before (expiresAt − 60s skew) → still valid. + assert.equal( + isAccessTokenExpired({ accessToken: 'a', expiresAt: 100_000 }, 10_000), + false, + ); + // now inside the 60s skew window before expiry → treated as expired. + assert.equal( + isAccessTokenExpired({ accessToken: 'a', expiresAt: 100_000 }, 50_000), + true, + ); + assert.equal( + isAccessTokenExpired({ accessToken: 'a', expiresAt: 100_000 }, 100_000), + true, + ); + }); +}); diff --git a/middleware/test/llmProviderOAuthTokenStore.test.ts b/middleware/test/llmProviderOAuthTokenStore.test.ts new file mode 100644 index 000000000..aa1a74399 --- /dev/null +++ b/middleware/test/llmProviderOAuthTokenStore.test.ts @@ -0,0 +1,167 @@ +/** + * Process-wide OAuth token store (#294) — single-flight refresh, rotation + * persisted before callers resolve, newest-wins hydration across divergent + * vault scopes, and the terminal reconnect-required latch. + */ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + getProviderOAuthBearer, + isProviderOAuthReconnectRequired, + registerProviderOAuthStoreBinding, + __resetProviderOAuthTokenStore, + type FetchLike, + type OAuthTokens, +} from '@omadia/llm-provider'; + +const PROVIDER = 'openai-chatgpt'; +const CFG = { issuer: 'https://issuer', clientId: 'cid', userAgent: 'ua' }; + +/** A refresh-only fetch double: every call returns a fresh rotated token. */ +function refreshFetch( + onCall: () => { status?: number; body: unknown }, +): { fetch: FetchLike; count: () => number } { + let n = 0; + const fetch: FetchLike = async () => { + n += 1; + const r = onCall(); + return { + ok: (r.status ?? 200) < 400, + status: r.status ?? 200, + json: async () => r.body, + text: async () => JSON.stringify(r.body), + } as Awaited>; + }; + return { fetch, count: () => n }; +} + +afterEach(() => __resetProviderOAuthTokenStore()); + +describe('providerOAuthTokenStore', () => { + it('single-flights concurrent refreshes into ONE token-endpoint call', async () => { + const stale: OAuthTokens = { accessToken: 'old', refreshToken: 'r0', expiresAt: 0 }; + const persisted: OAuthTokens[] = []; + const { fetch, count } = refreshFetch(() => ({ + body: { access_token: 'new', refresh_token: 'r1', expires_in: 3600 }, + })); + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => [{ tokens: stale, updatedAt: 1 }], + persist: async (t) => { + persisted.push(t); + }, + }); + + const deps = { fetchImpl: fetch, config: CFG, nowMs: () => 1_000_000 }; + const results = await Promise.all([ + getProviderOAuthBearer(PROVIDER, deps), + getProviderOAuthBearer(PROVIDER, deps), + getProviderOAuthBearer(PROVIDER, deps), + ]); + assert.deepEqual(results, ['new', 'new', 'new']); + assert.equal(count(), 1); // single-flight: one refresh for three callers + assert.equal(persisted.length, 1); // rotation persisted once, before resolve + assert.equal(persisted[0]?.refreshToken, 'r1'); + }); + + it('hydrates newest-wins across divergent scope copies', async () => { + // An older scope still holds a pre-rotation refresh token; the store must + // pick the NEWER copy (higher updatedAt), not resurrect the stale one. + const older: OAuthTokens = { accessToken: 'a-old', refreshToken: 'stale', expiresAt: 10 ** 15 }; + const newer: OAuthTokens = { accessToken: 'a-new', refreshToken: 'fresh', expiresAt: 10 ** 15 }; + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => [ + { tokens: older, updatedAt: 100 }, + { tokens: newer, updatedAt: 200 }, + ], + persist: async () => undefined, + }); + // Access token is far from expiry → no refresh, just hydration. + const bearer = await getProviderOAuthBearer(PROVIDER, { + config: CFG, + nowMs: () => 1, + }); + assert.equal(bearer, 'a-new'); + }); + + it('latches reconnect_required on a terminal refresh error and stops retrying', async () => { + const stale: OAuthTokens = { accessToken: 'old', refreshToken: 'dead', expiresAt: 0 }; + const { fetch, count } = refreshFetch(() => ({ + status: 400, + body: { error: { code: 'refresh_token_reused' } }, + })); + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => [{ tokens: stale, updatedAt: 1 }], + persist: async () => undefined, + }); + const deps = { fetchImpl: fetch, config: CFG, nowMs: () => 1_000_000 }; + + await assert.rejects(() => getProviderOAuthBearer(PROVIDER, deps)); + assert.equal(isProviderOAuthReconnectRequired(PROVIDER), true); + // A second call fails fast from the cached latch — no new endpoint hit. + await assert.rejects(() => getProviderOAuthBearer(PROVIDER, deps)); + assert.equal(count(), 1); + }); + + it('keeps the refreshed token in memory even when persistence throws', async () => { + // The old refresh token is already dead server-side; a transient vault + // failure must NOT drop the rotated token (which would wedge the grant). + const stale: OAuthTokens = { accessToken: 'old', refreshToken: 'r0', expiresAt: 0 }; + const { fetch } = refreshFetch(() => ({ + body: { access_token: 'new', refresh_token: 'r1', expires_in: 3600 }, + })); + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => [{ tokens: stale, updatedAt: 1 }], + persist: async () => { + throw new Error('vault down'); + }, + }); + const deps = { fetchImpl: fetch, config: CFG, nowMs: () => 1_000_000, log: () => undefined }; + + const bearer = await getProviderOAuthBearer(PROVIDER, deps); + assert.equal(bearer, 'new'); // rotation kept despite persist failure + assert.equal(isProviderOAuthReconnectRequired(PROVIDER), false); + // A second call reuses the in-memory rotated token — no reuse of r0. + const again = await getProviderOAuthBearer(PROVIDER, { + config: CFG, + nowMs: () => 1_000_001, + }); + assert.equal(again, 'new'); + }); + + it('does not throw "no tokens stored" for a second caller during a slow hydration', async () => { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const valid: OAuthTokens = { accessToken: 'v', refreshToken: 'r', expiresAt: 10 ** 15 }; + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => { + await gate; // hydration spans many ticks, like the real vault binding + return [{ tokens: valid, updatedAt: 1 }]; + }, + persist: async () => undefined, + }); + const deps = { config: CFG, nowMs: () => 1 }; + const p1 = getProviderOAuthBearer(PROVIDER, deps); + const p2 = getProviderOAuthBearer(PROVIDER, deps); + release(); + assert.deepEqual(await Promise.all([p1, p2]), ['v', 'v']); + }); + + it('serves a still-valid access token without any refresh', async () => { + const fresh: OAuthTokens = { accessToken: 'valid', refreshToken: 'r', expiresAt: 10 ** 15 }; + const { fetch, count } = refreshFetch(() => ({ body: {} })); + registerProviderOAuthStoreBinding(PROVIDER, { + load: async () => [{ tokens: fresh, updatedAt: 1 }], + persist: async () => undefined, + }); + const bearer = await getProviderOAuthBearer(PROVIDER, { + fetchImpl: fetch, + config: CFG, + nowMs: () => 1, + }); + assert.equal(bearer, 'valid'); + assert.equal(count(), 0); + }); +}); diff --git a/web-ui/app/_lib/api.ts b/web-ui/app/_lib/api.ts index 55e85f8d9..a3262a8be 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -395,6 +395,10 @@ export interface AdminProvider { * offer "Anmelden" for a CLI that isn't there. Absent on payloads from a * pre-OM-11 middleware — treat `undefined` as installed. */ installed?: boolean; + /** #294 — the provider connects via an OAuth device flow ("Sign in with + * ChatGPT"), so the UI renders a connect button + device-code modal instead + * of a vault key field. Absent on pre-#294 middleware payloads. */ + oauthConnect?: boolean; models: AdminProviderModel[]; } @@ -458,6 +462,48 @@ export async function verifyProvider( ); } +// ----------------------------------------------------------------------------- +// #294 — "Sign in with ChatGPT" OAuth device flow. `start` returns a user code +// the operator types at the verification URL; `poll` is called on `interval` +// until it reports complete/expired. +// ----------------------------------------------------------------------------- + +export interface ProviderOAuthStart { + flowId: string; + userCode: string; + verificationUri: string; + interval: number; +} + +export type ProviderOAuthPollStatus = 'pending' | 'complete' | 'expired' | 'error'; + +export interface ProviderOAuthPoll { + status: ProviderOAuthPollStatus; +} + +/** Begin the device flow for an OAuth provider (defaults to `openai-chatgpt`). */ +export async function startProviderOAuth( + provider: string, +): Promise { + return postJson('/v1/admin/providers/oauth/start', { provider }); +} + +/** Poll a running device flow once. The server answers a stale/expired flow + * with 404 `{status:'expired'}` and a backend error with 502 `{status:'error'}`; + * both are terminal poll states, so map the thrown ApiError back to them + * instead of surfacing a generic failure. */ +export async function pollProviderOAuth(flowId: string): Promise { + try { + return await postJson('/v1/admin/providers/oauth/poll', { flowId }); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 404) return { status: 'expired' }; + if (err.status === 502) return { status: 'error' }; + } + throw err; + } +} + // ----------------------------------------------------------------------------- // Subscription-CLI backends (#309) — detect installed/logged-in vendor CLIs // (Claude/Codex/Gemini) so an operator can run agents on a subscription instead diff --git a/web-ui/app/_lib/errorHelp.ts b/web-ui/app/_lib/errorHelp.ts index 1dbc04eb3..79149b2de 100644 --- a/web-ui/app/_lib/errorHelp.ts +++ b/web-ui/app/_lib/errorHelp.ts @@ -61,6 +61,10 @@ export const ERROR_HELP_CODES = [ 'providers.key_rejected', 'providers.model_provider_mismatch', 'providers.not_installed', + 'providers.oauth_poll_failed', + 'providers.oauth_start_failed', + 'providers.oauth_too_many_flows', + 'providers.oauth_unsupported', 'providers.read_failed', 'providers.tool_incompatible', 'providers.unknown_plugin', diff --git a/web-ui/app/admin/providers/_components/ChatGptConnectModal.tsx b/web-ui/app/admin/providers/_components/ChatGptConnectModal.tsx new file mode 100644 index 000000000..ccdad41d7 --- /dev/null +++ b/web-ui/app/admin/providers/_components/ChatGptConnectModal.tsx @@ -0,0 +1,198 @@ +'use client'; + +/** + * "Sign in with ChatGPT" device-code modal (#294, EXPERIMENTAL). + * + * Drives the provider OAuth device flow: start → show the user code + a link to + * the verification page → poll on the server-supplied interval until the login + * completes, expires, or errors. Surfaces a prominent ToS / experimental notice + * because driving programmatic calls through a consumer ChatGPT subscription is + * a grey area of OpenAI's terms. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +import { Button } from '@/app/_components/ui/Button'; +import { + pollProviderOAuth, + startProviderOAuth, + type ProviderOAuthStart, +} from '../../../_lib/api'; + +type T = ReturnType; + +type Phase = + | { phase: 'starting' } + | { phase: 'awaiting'; start: ProviderOAuthStart } + | { phase: 'done' } + | { phase: 'expired' } + | { phase: 'error'; detail?: string }; + +/** Fallback poll cadence if the server does not supply one (seconds). */ +const DEFAULT_INTERVAL_S = 5; + +export function ChatGptConnectModal({ + providerId, + t, + onClose, + onConnected, +}: { + providerId: string; + t: T; + onClose: () => void; + onConnected: () => void; +}): React.ReactElement { + const [phase, setPhase] = useState({ phase: 'starting' }); + const cancelled = useRef(false); + + useEffect(() => { + cancelled.current = false; + return () => { + cancelled.current = true; + }; + }, []); + + const beginFlow = useCallback(async () => { + setPhase({ phase: 'starting' }); + try { + const start = await startProviderOAuth(providerId); + if (cancelled.current) return; + setPhase({ phase: 'awaiting', start }); + } catch (err) { + if (!cancelled.current) { + setPhase({ phase: 'error', detail: err instanceof Error ? err.message : String(err) }); + } + } + }, [providerId]); + + useEffect(() => { + void beginFlow(); + }, [beginFlow]); + + // Poll while awaiting approval. + useEffect(() => { + if (phase.phase !== 'awaiting') return; + const intervalMs = (phase.start.interval || DEFAULT_INTERVAL_S) * 1000; + let active = true; + const timer = setInterval(() => { + void (async () => { + try { + const res = await pollProviderOAuth(phase.start.flowId); + if (!active || cancelled.current) return; + if (res.status === 'complete') { + clearInterval(timer); + setPhase({ phase: 'done' }); + onConnected(); + } else if (res.status === 'expired') { + clearInterval(timer); + setPhase({ phase: 'expired' }); + } else if (res.status === 'error') { + clearInterval(timer); + setPhase({ phase: 'error' }); + } + // 'pending' → keep polling. + } catch (err) { + if (!active || cancelled.current) return; + clearInterval(timer); + setPhase({ phase: 'error', detail: err instanceof Error ? err.message : String(err) }); + } + })(); + }, intervalMs); + return () => { + active = false; + clearInterval(timer); + }; + }, [phase, onConnected]); + + return ( +
+
+
+

+ {t('oauth.title')} +

+ {/* eslint-disable-next-line no-restricted-syntax -- icon-only close chrome, no §4.2 variant */} + +
+ + {/* ToS / experimental caveat — always visible. */} +

+ {t('oauth.tosNotice')} +

+ + {phase.phase === 'starting' && ( +

{t('oauth.starting')}

+ )} + + {phase.phase === 'awaiting' && ( +
+
    +
  1. + {t('oauth.openHint')}{' '} + + {t('oauth.openLink')} + +
  2. +
  3. + {t('oauth.enterCode')} +
    + {phase.start.userCode} +
    +
  4. +
  5. {t('oauth.waiting')}
  6. +
+
+ )} + + {phase.phase === 'done' && ( +

{t('oauth.connected')}

+ )} + + {phase.phase === 'expired' && ( +
+

{t('oauth.expired')}

+
+ +
+
+ )} + + {phase.phase === 'error' && ( +
+

{t('oauth.failed')}

+ {phase.detail ? ( +

{phase.detail}

+ ) : null} +
+ +
+
+ )} +
+
+ ); +} diff --git a/web-ui/app/admin/providers/_components/ProvidersPanel.tsx b/web-ui/app/admin/providers/_components/ProvidersPanel.tsx index d25a08f60..9d322d64b 100644 --- a/web-ui/app/admin/providers/_components/ProvidersPanel.tsx +++ b/web-ui/app/admin/providers/_components/ProvidersPanel.tsx @@ -6,6 +6,7 @@ import { useFormatter, useTranslations } from 'next-intl'; import { Button } from '@/app/_components/ui/Button'; import { ErrorHelp } from '@/app/_components/ErrorHelp'; +import { ChatGptConnectModal } from './ChatGptConnectModal'; import { assignProvider, getProviders, @@ -245,6 +246,8 @@ function ProviderRow({ // string the server produced per key) or the thrown ApiError itself. const [saveError, setSaveError] = useState(undefined); const [verifying, setVerifying] = useState(false); + // #294 — device-flow connect modal for an OAuth provider ("Sign in with ChatGPT"). + const [oauthOpen, setOauthOpen] = useState(false); const envKey = providerKeyEnv(p.id); const inputId = `provider-key-${p.id}`; // OM-11 — the CLI this provider needs is not on this server. `undefined` @@ -329,8 +332,9 @@ function ProviderRow({ {/* Explicit re-probe. Only offered where there is a credential to - probe — the CLI provider authenticates on the Subscriptions tab. */} - {!p.toolLess && p.status !== 'no_key' && ( + probe — the CLI provider authenticates on the Subscriptions tab, + and an OAuth provider has no key to probe. */} + {!p.toolLess && !p.oauthConnect && p.status !== 'no_key' && ( // eslint-disable-next-line no-restricted-syntax -- inline text link (bare accent text, no border/bg) + ) : p.toolLess ? ( // Subscription CLI: connect/manage via the in-app login on the // Subscriptions tab, not a vault key — switch tabs in place. // @@ -488,6 +503,19 @@ function ProviderRow({ {/* removeKey runs while `editing` is false, so surface its failures here — otherwise a destructive remove that errors gives the operator no feedback. */} {!p.toolLess && !editing && } + + {/* #294 — the device-code modal for an OAuth provider connect. */} + {oauthOpen && ( + setOauthOpen(false)} + onConnected={() => { + setOauthOpen(false); + void onReload(); + }} + /> + )} ); } diff --git a/web-ui/app/admin/providers/_components/__tests__/ChatGptConnectModal.test.tsx b/web-ui/app/admin/providers/_components/__tests__/ChatGptConnectModal.test.tsx new file mode 100644 index 000000000..772ed5228 --- /dev/null +++ b/web-ui/app/admin/providers/_components/__tests__/ChatGptConnectModal.test.tsx @@ -0,0 +1,102 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTranslations } from 'next-intl'; + +import { renderWithIntl } from '../../../../_lib/test-utils'; +import { ChatGptConnectModal } from '../ChatGptConnectModal'; + +/** + * #294 — the "Sign in with ChatGPT" device-code modal. It must start the flow, + * show the user code + verification link + the ToS caveat, poll on the + * server-supplied interval, and fire onConnected exactly once on completion. + */ +const { mockStart, mockPoll } = vi.hoisted(() => ({ + mockStart: vi.fn(), + mockPoll: vi.fn(), +})); + +vi.mock('../../../../_lib/api', () => ({ + startProviderOAuth: mockStart, + pollProviderOAuth: mockPoll, +})); + +/** Bridge the real message catalog into the component's `t` for the test. */ +function Harness(props: { + onClose: () => void; + onConnected: () => void; +}): React.ReactElement { + const t = useTranslations('adminProviders'); + return ( + + ); +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('shows the user code, the verification link and the ToS notice', async () => { + mockStart.mockResolvedValue({ + flowId: 'f1', + userCode: 'ABCD-1234', + verificationUri: 'https://auth.openai.com/codex/device', + interval: 5, + }); + mockPoll.mockResolvedValue({ status: 'pending' }); + + renderWithIntl( {}} onConnected={() => {}} />, { + locale: 'en', + }); + + expect(await screen.findByTestId('oauth-user-code')).toHaveTextContent('ABCD-1234'); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://auth.openai.com/codex/device'); + // The experimental / ToS caveat must always be visible. + expect(screen.getByText(/grey area of OpenAI/i)).toBeTruthy(); + }); + + it('fires onConnected when a poll reports complete', async () => { + mockStart.mockResolvedValue({ + flowId: 'f1', + userCode: 'X', + verificationUri: 'https://x/codex/device', + interval: 0.05, // fast poll for the test + }); + mockPoll.mockResolvedValueOnce({ status: 'pending' }).mockResolvedValue({ status: 'complete' }); + const onConnected = vi.fn(); + + renderWithIntl( {}} onConnected={onConnected} />, { + locale: 'en', + }); + + await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1)); + }); + + it('offers a retry when the flow expires', async () => { + mockStart.mockResolvedValue({ + flowId: 'f1', + userCode: 'X', + verificationUri: 'https://x/codex/device', + interval: 0.05, + }); + mockPoll.mockResolvedValue({ status: 'expired' }); + + renderWithIntl( {}} onConnected={() => {}} />, { + locale: 'en', + }); + + const retry = await screen.findByRole('button', { name: /Try again/i }); + expect(retry).toBeTruthy(); + // Retrying restarts the flow. + mockStart.mockClear(); + fireEvent.click(retry); + await waitFor(() => expect(mockStart).toHaveBeenCalled()); + }); +}); diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 33ec606fb..bb37ea74c 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -984,6 +984,22 @@ "next": "Installiere das Plugin zuerst und setze danach seinen Provider.", "action": "Zum Store" }, + "oauth_poll_failed": { + "what": "Die Prüfung, ob die ChatGPT-Anmeldung bestätigt wurde, ist fehlgeschlagen.", + "next": "Kurz warten — es wird automatisch erneut versucht; bleibt es fehlerhaft, den Dialog schließen und die Anmeldung neu starten." + }, + "oauth_start_failed": { + "what": "Die ChatGPT-Anmeldung konnte nicht gestartet werden.", + "next": "Gleich noch einmal versuchen. Hält es an, ist der OpenAI-Anmeldedienst von diesem Server aus evtl. nicht erreichbar." + }, + "oauth_too_many_flows": { + "what": "Auf diesem Server laufen bereits zu viele ChatGPT-Anmeldungen.", + "next": "Warten, bis die laufenden abgeschlossen sind oder ablaufen, dann die Anmeldung erneut starten." + }, + "oauth_unsupported": { + "what": "Dieser Provider kann nicht per ChatGPT-Anmeldung verbunden werden.", + "next": "Für diesen Provider stattdessen einen API-Key verwenden oder den ChatGPT-Abo-Provider wählen." + }, "read_failed": { "what": "Die Provider-Liste konnte nicht gelesen werden.", "next": "Lade die Seite neu. Bleibt sie leer, erreicht die Middleware ihren Config-Store nicht." @@ -2828,7 +2844,9 @@ "httpError": "Der Provider hat einen Fehler zurückgegeben (serverseitig oder Rate-Limit). Nicht dein Schlüssel.", "networkError": "Der Provider war nicht erreichbar — Timeout, DNS oder keine Verbindung.", "noProbe": "Dieser Provider-Typ lässt sich nicht günstig prüfen, der Schlüssel bleibt daher unbestätigt." - } + }, + "oauthConnect": "Mit ChatGPT anmelden", + "oauthReconnect": "ChatGPT neu verbinden" }, "assignments": { "heading": "Zuordnung pro Agent", @@ -2846,6 +2864,20 @@ "saving": "speichert …", "saved": "✓ gespeichert", "errorChip": "✗ Fehler" + }, + "oauth": { + "title": "Mit ChatGPT anmelden", + "close": "Schließen", + "tosNotice": "Experimentell. API-Aufrufe über ein persönliches ChatGPT-Abo zu betreiben, ist eine Grauzone der OpenAI-Bedingungen. Nicht als Enterprise-Funktion nutzen — kann jederzeit ohne Vorankündigung ausfallen.", + "starting": "Anmeldung wird gestartet…", + "openHint": "Öffne die ChatGPT-Geräteseite:", + "openLink": "Bestätigungsseite öffnen", + "enterCode": "Gib dort diesen Code ein:", + "waiting": "Komm dann hierher zurück — dieser Dialog schließt sich automatisch, sobald du bestätigt hast.", + "connected": "Verbunden. ChatGPT steht jetzt als Provider zur Verfügung.", + "expired": "Der Anmeldecode ist abgelaufen, bevor er bestätigt wurde.", + "failed": "Die Anmeldung konnte nicht abgeschlossen werden.", + "retry": "Erneut versuchen" } }, "adminSubscriptionClis": { diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index a999c42dd..c30e8186a 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -984,6 +984,22 @@ "next": "Install the plugin first, then set its provider.", "action": "Open the store" }, + "oauth_poll_failed": { + "what": "Checking whether the ChatGPT login was approved failed.", + "next": "Wait a moment and it retries on its own; if it keeps failing, close the dialog and start the login again." + }, + "oauth_start_failed": { + "what": "The ChatGPT login could not be started.", + "next": "Try again in a moment. If it persists, the OpenAI login service may be unreachable from this server." + }, + "oauth_too_many_flows": { + "what": "Too many ChatGPT logins are already in progress on this server.", + "next": "Wait for the pending ones to finish or expire, then start the login again." + }, + "oauth_unsupported": { + "what": "This provider cannot be connected with a ChatGPT login.", + "next": "Use an API key for this provider instead, or pick the ChatGPT subscription provider." + }, "read_failed": { "what": "The provider list could not be read.", "next": "Reload the page. If it stays empty, the middleware cannot reach its config store." @@ -2828,7 +2844,9 @@ "httpError": "The provider returned an error (server-side or rate limit). Not your key.", "networkError": "The provider could not be reached — timeout, DNS or no connection.", "noProbe": "This provider type cannot be probed cheaply, so the key stays unverified." - } + }, + "oauthConnect": "Sign in with ChatGPT", + "oauthReconnect": "Reconnect ChatGPT" }, "assignments": { "heading": "Per-agent assignment", @@ -2846,6 +2864,20 @@ "saving": "saving …", "saved": "✓ saved", "errorChip": "✗ error" + }, + "oauth": { + "title": "Sign in with ChatGPT", + "close": "Close", + "tosNotice": "Experimental. Driving API calls through a personal ChatGPT subscription is a grey area of OpenAI’s terms. Do not use it as an enterprise feature, and expect it to break without notice.", + "starting": "Starting the login…", + "openHint": "Open the ChatGPT device page:", + "openLink": "Open verification page", + "enterCode": "Enter this code there:", + "waiting": "Then come back here — this dialog completes automatically once you approve.", + "connected": "Connected. ChatGPT is now available as a provider.", + "expired": "The login code expired before it was approved.", + "failed": "The login could not be completed.", + "retry": "Try again" } }, "adminSubscriptionClis": {