diff --git a/.env.example b/.env.example index eb099cb..34b3f3a 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ OWNER_ID=opendots-owner # Required for an external HOST binding (24+ characters); enables local login too. # OWNER_TOKEN= +# `copilotkit project select` rewrites this line to CPK_INTELLIGENCE_API_KEY. +# The server accepts either name and prefers a non-empty CPK_INTELLIGENCE_API_KEY. INTELLIGENCE_API_KEY= # INTELLIGENCE_API_URL= # INTELLIGENCE_WS_URL= diff --git a/compose.yml b/compose.yml index 9f23992..9b5cf82 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,7 @@ services: APP_ORIGIN: ${APP_ORIGIN:-http://localhost:4310} OWNER_ID: ${OWNER_ID:-opendots-owner} INTELLIGENCE_API_KEY: ${INTELLIGENCE_API_KEY:-} + CPK_INTELLIGENCE_API_KEY: ${CPK_INTELLIGENCE_API_KEY:-} INTELLIGENCE_API_URL: ${INTELLIGENCE_API_URL:-} INTELLIGENCE_WS_URL: ${INTELLIGENCE_WS_URL:-} VOICE_API_KEY: ${VOICE_API_KEY:-} diff --git a/docs/SETUP.md b/docs/SETUP.md index 077c0ac..22795cc 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -38,6 +38,8 @@ Edit `.env` on the server and restart after changes: | `OWNER_TOKEN` | Application access token; required for external bindings | | `APP_ORIGIN` | Exact browser origin when using a proxy or custom domain | +`npx copilotkit project select` deletes an `INTELLIGENCE_API_KEY` line and writes the same credential as `CPK_INTELLIGENCE_API_KEY`. The server reads either name. A non-empty `CPK_INTELLIGENCE_API_KEY` takes precedence over `INTELLIGENCE_API_KEY`. + The model environment variable names follow the configured provider adapter. Provider credentials belong in `.env`, not client-side variables or source code. Conversation history lives in the configured Intelligence project; copying the SQLite file alone does not back up that history. ## Pages and page conversations @@ -135,8 +137,9 @@ For remote hosting, configure an HTTPS reverse proxy and the matching `APP_ORIGI ## Automatic Learning OpenDots connects [CopilotKit Automatic Learning](https://docs.copilotkit.ai/learning) -to individual Dots. It uses the existing server-side `INTELLIGENCE_API_KEY` and -optional `INTELLIGENCE_API_URL`; no additional model key or frontend key is needed. +to individual Dots. It uses the server-side Intelligence credential +(`CPK_INTELLIGENCE_API_KEY` or `INTELLIGENCE_API_KEY`) and optional +`INTELLIGENCE_API_URL`; no additional model key or frontend key is needed. 1. Open **Learning** in the same Intelligence project and create a container for one focused workflow, such as `research-workflow`. IDs use 1–64 lowercase diff --git a/src/server/index.ts b/src/server/index.ts index 6f8978e..5149f5f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,7 +8,10 @@ import { Runner } from './runner.js'; import { createApp } from './app.js'; import { WorkspaceStore } from './workspace.js'; import { Platform } from './platform.js'; -import type { PlatformConfig } from './platform-config.js'; +import { + intelligenceApiKeyFromEnv, + type PlatformConfig, +} from './platform-config.js'; const host = process.env.HOST ?? '127.0.0.1'; const port = Number(process.env.PORT ?? 4310); const ownerToken = process.env.OWNER_TOKEN; @@ -26,7 +29,7 @@ const workspace = new WorkspaceStore( process.env.OWNER_ID ?? 'opendots-owner', ); const config: PlatformConfig = { - intelligenceKey: process.env.INTELLIGENCE_API_KEY, + intelligenceKey: intelligenceApiKeyFromEnv(process.env), intelligenceApiUrl: process.env.INTELLIGENCE_API_URL || undefined, intelligenceWsUrl: process.env.INTELLIGENCE_WS_URL || undefined, apiKey: process.env.OPENAI_API_KEY, diff --git a/src/server/platform-config.ts b/src/server/platform-config.ts index 4d4e8ad..0502f91 100644 --- a/src/server/platform-config.ts +++ b/src/server/platform-config.ts @@ -1,5 +1,23 @@ import type { WebConfig } from './parallel.js'; import type { SetupStatus } from '../shared/types.js'; + +// `copilotkit project select` writes the CLI name and deletes the template name. +const INTELLIGENCE_API_KEY_ENV_NAMES = [ + 'CPK_INTELLIGENCE_API_KEY', + 'INTELLIGENCE_API_KEY', +] as const; +export const INTELLIGENCE_KEY_MISSING_LABEL = `${INTELLIGENCE_API_KEY_ENV_NAMES[1]} (or ${INTELLIGENCE_API_KEY_ENV_NAMES[0]})`; + +export function intelligenceApiKeyFromEnv( + env: Record, +): string | undefined { + for (const name of INTELLIGENCE_API_KEY_ENV_NAMES) { + const value = env[name]?.trim(); + if (value) return value; + } + return undefined; +} + export interface PlatformConfig extends WebConfig { intelligenceKey?: string; intelligenceApiUrl?: string; @@ -29,7 +47,7 @@ export function setupStatus( activationFailed = false, ): SetupStatus { const missing = [ - !config.intelligenceKey && 'INTELLIGENCE_API_KEY', + !config.intelligenceKey && INTELLIGENCE_KEY_MISSING_LABEL, !config.apiKey && 'OPENAI_API_KEY', !config.model && 'OPENAI_MODEL', ].filter((item): item is string => !!item); diff --git a/src/server/platform.ts b/src/server/platform.ts index 7d19ad4..26be71b 100644 --- a/src/server/platform.ts +++ b/src/server/platform.ts @@ -13,7 +13,11 @@ import { Store } from './store.js'; import { WorkspaceStore } from './workspace.js'; import { DotAgent } from './dot-agent.js'; import { runThreadTurn } from './headless.js'; -import { setupStatus, type PlatformConfig } from './platform-config.js'; +import { + INTELLIGENCE_KEY_MISSING_LABEL, + setupStatus, + type PlatformConfig, +} from './platform-config.js'; import { validateRuntimeScope } from './runtime-scope.js'; import { learningSelector } from './learning.js'; export class Platform { @@ -151,7 +155,7 @@ export class Platform { async handle(request: Request): Promise { if (!this.handler) return Response.json( - { error: 'Setup required: INTELLIGENCE_API_KEY.' }, + { error: `Setup required: ${INTELLIGENCE_KEY_MISSING_LABEL}.` }, { status: 503 }, ); let body: unknown; diff --git a/tests/intelligence-key.test.ts b/tests/intelligence-key.test.ts new file mode 100644 index 0000000..ee0db3f --- /dev/null +++ b/tests/intelligence-key.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from 'vitest'; +import { Platform } from '../src/server/platform.js'; +import { + INTELLIGENCE_KEY_MISSING_LABEL, + intelligenceApiKeyFromEnv, + setupStatus, + type PlatformConfig, +} from '../src/server/platform-config.js'; +import { Store } from '../src/server/store.js'; +import { WorkspaceStore } from '../src/server/workspace.js'; + +const configured: PlatformConfig = { + apiKey: 'fixture', + model: 'fixture', + baseUrl: 'https://example.com', + runtimeUrl: '', + voiceName: 'marin', + slackUsers: [], +}; + +it('accepts the CLI project key and still accepts the template name', () => { + expect( + intelligenceApiKeyFromEnv({ CPK_INTELLIGENCE_API_KEY: 'cpk-from-cli' }), + ).toBe('cpk-from-cli'); + expect(intelligenceApiKeyFromEnv({ INTELLIGENCE_API_KEY: 'legacy' })).toBe( + 'legacy', + ); + expect( + intelligenceApiKeyFromEnv({ + CPK_INTELLIGENCE_API_KEY: 'cpk-from-cli', + INTELLIGENCE_API_KEY: 'legacy', + }), + ).toBe('cpk-from-cli'); + expect( + intelligenceApiKeyFromEnv({ + CPK_INTELLIGENCE_API_KEY: ' ', + INTELLIGENCE_API_KEY: 'legacy', + }), + ).toBe('legacy'); + expect(intelligenceApiKeyFromEnv({})).toBeUndefined(); +}); + +it('reports either accepted name from the same label used by setup status and the copilotkit error', async () => { + const key = intelligenceApiKeyFromEnv({ + CPK_INTELLIGENCE_API_KEY: 'cpk-from-cli', + }); + expect(setupStatus({ ...configured, intelligenceKey: key })).toMatchObject({ + intelligence: true, + missing: [], + }); + expect(INTELLIGENCE_KEY_MISSING_LABEL).toContain('CPK_INTELLIGENCE_API_KEY'); + expect(INTELLIGENCE_KEY_MISSING_LABEL).toContain('INTELLIGENCE_API_KEY'); + expect(setupStatus(configured).missing).toContain( + INTELLIGENCE_KEY_MISSING_LABEL, + ); + + const store = new Store(':memory:'); + const workspace = new WorkspaceStore(':memory:', 'owner'); + try { + const platform = new Platform(store, workspace, configured); + const response = await platform.handle( + new Request('http://127.0.0.1/api/copilotkit/info'), + ); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: `Setup required: ${INTELLIGENCE_KEY_MISSING_LABEL}.`, + }); + expect(platform.setup().missing).toContain(INTELLIGENCE_KEY_MISSING_LABEL); + } finally { + store.close(); + workspace.close(); + } +}); diff --git a/tests/runtime-scope.test.ts b/tests/runtime-scope.test.ts index 98bf6ff..d6718ba 100644 --- a/tests/runtime-scope.test.ts +++ b/tests/runtime-scope.test.ts @@ -1,7 +1,10 @@ import { expect, it } from 'vitest'; import { validateRuntimeScope } from '../src/server/runtime-scope.js'; import { WorkspaceStore } from '../src/server/workspace.js'; -import { setupStatus } from '../src/server/platform-config.js'; +import { + INTELLIGENCE_KEY_MISSING_LABEL, + setupStatus, +} from '../src/server/platform-config.js'; it('blocks unbound cross-Dot run and inspector routes before contacting Intelligence', () => { const store = new WorkspaceStore(':memory:', 'owner'); const dot = store.dots()[0]; @@ -43,7 +46,7 @@ it('reports setup honestly without a standalone agent fallback', () => { expect(status.intelligence).toBe(false); expect(status.voice).toBe(false); expect(status.slack).toBe('not_configured'); - expect(status.missing).toContain('INTELLIGENCE_API_KEY'); + expect(status.missing).toContain(INTELLIGENCE_KEY_MISSING_LABEL); }); it('rejects stop scope bypasses and misleading prefixes while allowing canonical owned routes', () => { const store = new WorkspaceStore(':memory:', 'owner'); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index db74aa7..4d91f3d 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -1,5 +1,6 @@ import { expect, it } from 'vitest'; import { + INTELLIGENCE_KEY_MISSING_LABEL, setupStatus, type PlatformConfig, } from '../src/server/platform-config.js'; @@ -37,7 +38,10 @@ it('requires Intelligence and model setup and disables voice when either is abse voiceKey: 'fixture', voiceModel: 'fixture', }), - ).toMatchObject({ missing: ['INTELLIGENCE_API_KEY'], voice: false }); + ).toMatchObject({ + missing: [INTELLIGENCE_KEY_MISSING_LABEL], + voice: false, + }); }); it('reports activation failure until the SDK recovers online', () => { const declared = {