-
Notifications
You must be signed in to change notification settings - Fork 0
feat(overseer): text conversation core + debug-settings surface (Stage 0) #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/overseer-readonly-entity
Are you sure you want to change the base?
Changes from all commits
d67b9f5
f8e20a9
b4abbeb
0096ccb
f5b84cf
5e64aa7
b5a76fe
1997929
b727f3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { filterChatModels, listBrainProfiles, resolveBrainConfig } from './brainClient' | ||
|
|
||
| const baseEnv = { | ||
| OVERSEER_BRAIN_URL: 'http://local.test/v1/', | ||
| OVERSEER_BRAIN_MODEL: 'main' | ||
| } as NodeJS.ProcessEnv | ||
|
|
||
| const multiEnv = { | ||
| ...baseEnv, | ||
| OVERSEER_BRAIN_PROFILE_OPENAI_URL: 'https://api.openai.com/v1', | ||
| OVERSEER_BRAIN_PROFILE_OPENAI_MODEL: 'gpt-4o', | ||
| OVERSEER_BRAIN_PROFILE_OPENAI_API_KEY: 'sk-test' | ||
| } as NodeJS.ProcessEnv | ||
|
|
||
| describe('resolveBrainConfig', () => { | ||
| it('reads the default profile and trims the trailing slash', () => { | ||
| const cfg = resolveBrainConfig(baseEnv) | ||
| expect(cfg).toMatchObject({ baseUrl: 'http://local.test/v1', model: 'main' }) | ||
| }) | ||
|
|
||
| it('returns null when no brain url is configured', () => { | ||
| expect(resolveBrainConfig({} as NodeJS.ProcessEnv)).toBeNull() | ||
| }) | ||
|
|
||
| it('applies a per-request model override', () => { | ||
| expect(resolveBrainConfig(baseEnv, { model: 'qwen3-32b' })?.model).toBe('qwen3-32b') | ||
| }) | ||
|
|
||
| it('selects a named profile (case-insensitive) with its own key', () => { | ||
| const cfg = resolveBrainConfig(multiEnv, { profile: 'openai' }) | ||
| expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiKey: 'sk-test' }) | ||
| }) | ||
|
|
||
| it('falls back to default for an unknown profile', () => { | ||
| expect(resolveBrainConfig(multiEnv, { profile: 'nope' })?.baseUrl).toBe('http://local.test/v1') | ||
| }) | ||
|
|
||
| it('model override wins over the selected profile model', () => { | ||
| expect(resolveBrainConfig(multiEnv, { profile: 'openai', model: 'gpt-4o-mini' })?.model).toBe('gpt-4o-mini') | ||
| }) | ||
| }) | ||
|
|
||
| describe('listBrainProfiles', () => { | ||
| it('lists the default plus named profiles (no url/key exposed)', () => { | ||
| const list = listBrainProfiles(multiEnv) | ||
| expect(list).toEqual([ | ||
| { id: 'default', label: 'Default', model: 'main', isDefault: true }, | ||
| { id: 'openai', label: 'openai', model: 'gpt-4o', isDefault: false } | ||
| ]) | ||
| }) | ||
|
|
||
| it('is empty when no brain is configured', () => { | ||
| expect(listBrainProfiles({} as NodeJS.ProcessEnv)).toEqual([]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('filterChatModels', () => { | ||
| it('drops non-chat models and sorts the rest', () => { | ||
| const filtered = filterChatModels([ | ||
| 'gpt-4o', | ||
| 'text-embedding-3-small', | ||
| 'gpt-4.1', | ||
| 'whisper-1', | ||
| 'dall-e-3', | ||
| 'gpt-3.5-turbo-instruct', | ||
| 'tts-1' | ||
| ]) | ||
| expect(filtered).toEqual(['gpt-4.1', 'gpt-4o']) | ||
| }) | ||
|
|
||
| it('keeps a single local model id like "main"', () => { | ||
| expect(filterChatModels(['main'])).toEqual(['main']) | ||
| }) | ||
|
|
||
| it('falls back to the raw list when filtering removes everything', () => { | ||
| expect(filterChatModels(['text-embedding-3-large'])).toEqual(['text-embedding-3-large']) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| /** | ||
| * OpenAI-compatible client for the Overseer "brain" LLM. | ||
| * | ||
| * The brain is any OpenAI chat-completions endpoint that supports tool calling | ||
| * (e.g. the estate `llama-server` serving Qwen3.6-27B at | ||
| * `https://oos-llm.tail9944ee.ts.net/v1`, model `main`). It runs on contended | ||
| * GPUs and can vanish (pulled for VR); every reachability failure is surfaced as | ||
| * `BrainUnavailableError` so callers can degrade gracefully instead of erroring. | ||
| */ | ||
|
|
||
| export type BrainConfig = { | ||
| /** Base URL including the `/v1` suffix. */ | ||
| baseUrl: string | ||
| model: string | ||
| apiKey?: string | ||
| timeoutMs: number | ||
| } | ||
|
|
||
| export type OpenAiToolCall = { | ||
| id?: string | ||
| type?: 'function' | ||
| function: { name: string; arguments: string } | ||
| } | ||
|
|
||
| export type OpenAiChatMessage = { | ||
| role: 'system' | 'user' | 'assistant' | 'tool' | ||
| content: string | null | ||
| tool_calls?: OpenAiToolCall[] | ||
| tool_call_id?: string | ||
| name?: string | ||
| } | ||
|
|
||
| export type OverseerOpenAiToolLike = { | ||
| type: 'function' | ||
| function: { name: string; description: string; parameters: Record<string, unknown> } | ||
| } | ||
|
|
||
| /** | ||
| * `kind` distinguishes a brain that is genuinely unreachable (network/timeout — | ||
| * e.g. GPU pulled for VR) from one that answered with an error (http 4xx/5xx or | ||
| * a malformed body). Callers use this so a chat-template 400 is not mislabeled | ||
| * to the operator as "brain offline". | ||
| */ | ||
| export type BrainErrorKind = 'unreachable' | 'timeout' | 'http' | 'protocol' | ||
|
|
||
| export class BrainUnavailableError extends Error { | ||
| constructor( | ||
| message: string, | ||
| readonly kind: BrainErrorKind = 'unreachable', | ||
| readonly status?: number, | ||
| readonly cause?: unknown | ||
| ) { | ||
| super(message) | ||
| this.name = 'BrainUnavailableError' | ||
| } | ||
|
|
||
| /** True when the brain was reachable but the request itself failed. */ | ||
| get reachable(): boolean { | ||
| return this.kind === 'http' || this.kind === 'protocol' | ||
| } | ||
| } | ||
|
|
||
| export type OverseerBrainProfileInfo = { | ||
| id: string | ||
| label: string | ||
| model: string | ||
| isDefault: boolean | ||
| } | ||
|
|
||
| function timeoutFromEnv(env: NodeJS.ProcessEnv): number { | ||
| return Number(env.OVERSEER_BRAIN_TIMEOUT_MS) > 0 ? Number(env.OVERSEER_BRAIN_TIMEOUT_MS) : 60_000 | ||
| } | ||
|
|
||
| /** Read a brain config from a set of env keys with the given prefix. */ | ||
| function readProfile(env: NodeJS.ProcessEnv, prefix: string): BrainConfig | null { | ||
| const baseUrl = env[`${prefix}URL`]?.trim() | ||
| if (!baseUrl) return null | ||
| return { | ||
| baseUrl: baseUrl.replace(/\/+$/, ''), | ||
| model: env[`${prefix}MODEL`]?.trim() || 'main', | ||
| apiKey: env[`${prefix}API_KEY`]?.trim() || undefined, | ||
| timeoutMs: timeoutFromEnv(env) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolve brain config from env, applying an optional profile + model override. | ||
| * | ||
| * Default profile: `OVERSEER_BRAIN_URL` / `_MODEL` / `_API_KEY`. | ||
| * Named profiles: `OVERSEER_BRAIN_PROFILE_<ID>_URL` / `_MODEL` / `_API_KEY` | ||
| * (so a frontier endpoint's key stays server-side, never in the browser). | ||
| * | ||
| * Returns null when the requested/default profile has no URL configured. | ||
| */ | ||
| export function resolveBrainConfig( | ||
| env: NodeJS.ProcessEnv = process.env, | ||
| opts: { profile?: string; model?: string } = {} | ||
| ): BrainConfig | null { | ||
| let cfg: BrainConfig | null = null | ||
| const profile = opts.profile?.trim() | ||
| if (profile && profile.toLowerCase() !== 'default') { | ||
| cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${profile.toUpperCase()}_`) | ||
| } | ||
| if (!cfg) cfg = readProfile(env, 'OVERSEER_BRAIN_') | ||
| if (!cfg) return null | ||
| const model = opts.model?.trim() | ||
| return model ? { ...cfg, model } : cfg | ||
| } | ||
|
|
||
| const NON_CHAT_MODEL = /embedding|whisper|tts|dall-?e|moderation|audio|realtime|image|transcribe|-search|babbage|davinci-002|instruct/i | ||
|
|
||
| /** Keep the chat-usable model ids (drop embeddings/audio/image/etc.), sorted. */ | ||
| export function filterChatModels(ids: string[]): string[] { | ||
| const chat = ids.filter((id) => !NON_CHAT_MODEL.test(id)) | ||
| return (chat.length > 0 ? chat : ids).slice().sort() | ||
| } | ||
|
|
||
| /** List model ids a brain endpoint serves (OpenAI-compatible GET /models). */ | ||
| export async function listBrainModels(config: BrainConfig, timeoutMs = 12_000): Promise<string[]> { | ||
| const controller = new AbortController() | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs) | ||
| let res: Response | ||
| try { | ||
| res = await fetch(`${config.baseUrl}/models`, { | ||
| headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}, | ||
| signal: controller.signal | ||
| }) | ||
| } catch (error) { | ||
| throw new BrainUnavailableError('Brain model list unreachable', 'unreachable', undefined, error) | ||
| } finally { | ||
| clearTimeout(timeout) | ||
| } | ||
| if (!res.ok) throw new BrainUnavailableError(`Brain model list returned ${res.status}`, 'http', res.status) | ||
| const json = (await res.json().catch(() => null)) as { data?: Array<{ id?: unknown }> } | null | ||
| return (json?.data ?? []) | ||
| .map((m) => m?.id) | ||
| .filter((id): id is string => typeof id === 'string' && id.length > 0) | ||
| } | ||
|
|
||
| /** List configured brain profiles for the UI (id/label/model only — no url/key). */ | ||
| export function listBrainProfiles(env: NodeJS.ProcessEnv = process.env): OverseerBrainProfileInfo[] { | ||
| const out: OverseerBrainProfileInfo[] = [] | ||
| const def = readProfile(env, 'OVERSEER_BRAIN_') | ||
| if (def) out.push({ id: 'default', label: 'Default', model: def.model, isDefault: true }) | ||
| for (const key of Object.keys(env)) { | ||
| const match = key.match(/^OVERSEER_BRAIN_PROFILE_(.+)_URL$/) | ||
| if (!match) continue | ||
| const id = match[1].toLowerCase() | ||
| const cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${match[1]}_`) | ||
| if (cfg) out.push({ id, label: id, model: cfg.model, isDefault: false }) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| /** | ||
| * One chat-completions round-trip. Returns the assistant message (which may | ||
| * carry `tool_calls`). Throws `BrainUnavailableError` on any transport failure, | ||
| * timeout, or non-2xx response. | ||
| */ | ||
| export async function callBrain(params: { | ||
| config: BrainConfig | ||
| messages: OpenAiChatMessage[] | ||
| tools?: OverseerOpenAiToolLike[] | ||
| temperature?: number | ||
| signal?: AbortSignal | ||
| }): Promise<OpenAiChatMessage> { | ||
| const { config, messages, tools, temperature = 0.2, signal } = params | ||
| const controller = new AbortController() | ||
| const timeout = setTimeout(() => controller.abort(), config.timeoutMs) | ||
| if (signal) { | ||
| if (signal.aborted) controller.abort() | ||
| else signal.addEventListener('abort', () => controller.abort(), { once: true }) | ||
| } | ||
|
|
||
| let res: Response | ||
| try { | ||
| res = await fetch(`${config.baseUrl}/chat/completions`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}) | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: config.model, | ||
| messages, | ||
| ...(tools && tools.length > 0 ? { tools, tool_choice: 'auto' } : {}), | ||
| temperature, | ||
| stream: false | ||
| }), | ||
| signal: controller.signal | ||
| }) | ||
| } catch (error) { | ||
| throw controller.signal.aborted | ||
| ? new BrainUnavailableError('Overseer brain timed out', 'timeout', undefined, error) | ||
| : new BrainUnavailableError('Overseer brain unreachable', 'unreachable', undefined, error) | ||
| } finally { | ||
| clearTimeout(timeout) | ||
|
Comment on lines
+196
to
+197
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the brain sends response headers but stalls or disconnects while producing the body, Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.text().catch(() => '') | ||
| throw new BrainUnavailableError(`Overseer brain returned ${res.status}: ${body.slice(0, 300)}`, 'http', res.status) | ||
| } | ||
|
|
||
| let json: unknown | ||
| try { | ||
| json = await res.json() | ||
| } catch (error) { | ||
| throw new BrainUnavailableError('Overseer brain returned invalid JSON', 'protocol', undefined, error) | ||
| } | ||
|
|
||
| const message = (json as { choices?: Array<{ message?: OpenAiChatMessage }> })?.choices?.[0]?.message | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an endpoint returns valid JSON with a truthy but malformed AGENTS.md reference: AGENTS.md:L61-L61 Useful? React with 👍 / 👎. |
||
| if (!message) { | ||
| throw new BrainUnavailableError('Overseer brain response missing a message', 'protocol') | ||
| } | ||
| return message | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the configured brain returns
/modelsheaders but stalls while sending the JSON body, thisfinallyclears the 12-second timeout beforeres.json()consumes the response. The Settings model dropdown can consequently remain loading indefinitely; clear the timer only after the body has been fully read, as is also required for the separate chat-completions request path.Useful? React with 👍 / 👎.