@animus-labs/cortex 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,13 +17,14 @@
17
17
  import {
18
18
  PROVIDER_REGISTRY,
19
19
  OAUTH_PROVIDER_IDS,
20
- UTILITY_MODEL_DEFAULTS,
20
+ UTILITY_MODEL_OVERRIDES,
21
21
  } from './provider-registry.js';
22
22
  import { createRequire } from 'node:module';
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
24
  import type { ThinkingLevel } from './types.js';
25
25
  import type { ProviderInfo, ModelInfo } from './provider-registry.js';
26
26
  import { wrapModel } from './model-wrapper.js';
27
+ import { inferUtilityModelId } from './utility-model-inference.js';
27
28
  import type { CortexModel } from './model-wrapper.js';
28
29
 
29
30
  const nodeRequire = createRequire(import.meta.url);
@@ -74,6 +75,16 @@ export interface OAuthCallbacks {
74
75
  * localhost callback routes and is restored immediately after the login flow.
75
76
  */
76
77
  renderCallbackPage?: OAuthCallbackPageRenderer | undefined;
78
+
79
+ /**
80
+ * Overall timeout for the OAuth flow, in milliseconds. pi-ai's
81
+ * callback-server flows (e.g. Anthropic) do not honor an abort signal and
82
+ * hang forever if the callback never arrives or arrives with an error, so
83
+ * Cortex enforces this timeout itself and rejects with an
84
+ * `OAuthError('timed_out')`. Defaults to 5 minutes. Pass `0` or a negative
85
+ * value to disable (not recommended).
86
+ */
87
+ timeoutMs?: number | undefined;
77
88
  }
78
89
 
79
90
  /** Status of the browser callback page produced by an OAuth flow. */
@@ -151,6 +162,48 @@ export interface OAuthRefreshResult {
151
162
  changed: boolean;
152
163
  }
153
164
 
165
+ /**
166
+ * Discriminant for OAuth flow failures, so consumers can render specific
167
+ * UX instead of parsing error strings.
168
+ *
169
+ * - `unsupported_provider`: provider has no OAuth support.
170
+ * - `callback_port_in_use`: the provider's fixed loopback callback port is
171
+ * already bound (e.g. another Anthropic app on 53692, or a leftover flow).
172
+ * Detected before the browser opens.
173
+ * - `cancelled`: the flow was cancelled via `cancelOAuth()`.
174
+ * - `timed_out`: the flow exceeded its timeout (pi-ai's callback servers do
175
+ * not honor an abort signal, so this is the backstop against hangs).
176
+ * - `callback_failed`: the browser callback fired but the provider reported
177
+ * an error (e.g. state mismatch). Surfaced immediately instead of hanging.
178
+ */
179
+ export type OAuthErrorCode =
180
+ | 'unsupported_provider'
181
+ | 'callback_port_in_use'
182
+ | 'cancelled'
183
+ | 'timed_out'
184
+ | 'callback_failed';
185
+
186
+ /** Structured error thrown by initiateOAuth. */
187
+ export class OAuthError extends Error {
188
+ readonly code: OAuthErrorCode;
189
+ readonly provider: string;
190
+ /** The fixed callback port, when relevant (`callback_port_in_use`). */
191
+ readonly port?: number | undefined;
192
+
193
+ constructor(
194
+ code: OAuthErrorCode,
195
+ provider: string,
196
+ message: string,
197
+ options?: { port?: number | undefined; cause?: unknown },
198
+ ) {
199
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
200
+ this.name = 'OAuthError';
201
+ this.code = code;
202
+ this.provider = provider;
203
+ this.port = options?.port;
204
+ }
205
+ }
206
+
154
207
  /** Configuration for creating a custom model endpoint. */
155
208
  export interface CustomModelConfig {
156
209
  /** Base URL of the OpenAI-compatible API (e.g., 'http://localhost:11434/v1'). */
@@ -255,7 +308,13 @@ interface ActiveOAuthCallbackPageShim {
255
308
  readonly provider: string;
256
309
  readonly providerName: string;
257
310
  readonly route: OAuthCallbackRoute;
258
- readonly render: OAuthCallbackPageRenderer;
311
+ readonly render: OAuthCallbackPageRenderer | undefined;
312
+ /**
313
+ * Notified exactly once when the browser callback fires and its status
314
+ * (success/error) is known. Lets the flow react immediately instead of
315
+ * waiting on pi-ai (which hangs on non-success callbacks).
316
+ */
317
+ readonly onResult?: ((status: OAuthCallbackPageStatus, context: OAuthCallbackPageContext) => void) | undefined;
259
318
  }
260
319
 
261
320
  type ServerResponseEnd = ServerResponse['end'];
@@ -266,30 +325,88 @@ const OAUTH_CALLBACK_ROUTES: Record<string, OAuthCallbackRoute> = {
266
325
  };
267
326
 
268
327
  let activeOAuthCallbackPageShim: ActiveOAuthCallbackPageShim | null = null;
328
+ /** Ensures `onResult` fires at most once per installed shim. */
329
+ let oauthCallbackResultNotified = false;
330
+
331
+ /** Default overall OAuth flow timeout (pi-ai hangs without this). */
332
+ const DEFAULT_OAUTH_FLOW_TIMEOUT_MS = 5 * 60_000;
333
+
334
+ /**
335
+ * Probe whether something is already listening on a loopback port. Used to
336
+ * fail an OAuth flow fast (before opening a browser) when the provider's
337
+ * fixed callback port is occupied — otherwise pi-ai binds the other stack /
338
+ * the browser hits the wrong listener and the user gets a dead page while
339
+ * pi-ai waits forever.
340
+ */
341
+ function probeCallbackPortInUse(port: number, host: string): Promise<boolean> {
342
+ const net = nodeRequire('node:net') as typeof import('node:net');
343
+ return new Promise((resolve) => {
344
+ let settled = false;
345
+ const finish = (inUse: boolean) => {
346
+ if (settled) return;
347
+ settled = true;
348
+ socket.destroy();
349
+ resolve(inUse);
350
+ };
351
+ const socket = net.connect({ port, host });
352
+ socket.once('connect', () => finish(true));
353
+ socket.once('error', () => finish(false));
354
+ socket.setTimeout(600, () => finish(false));
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Throw an `OAuthError('callback_port_in_use')` if the provider's fixed
360
+ * callback port is occupied on either IPv4 or IPv6 loopback. No-op for
361
+ * providers without a known callback route (manual/device-code flows).
362
+ */
363
+ async function assertOAuthCallbackPortAvailable(provider: string): Promise<void> {
364
+ const route = OAUTH_CALLBACK_ROUTES[provider];
365
+ if (!route) return;
366
+
367
+ for (const host of ['127.0.0.1', '::1']) {
368
+ if (await probeCallbackPortInUse(route.port, host)) {
369
+ throw new OAuthError(
370
+ 'callback_port_in_use',
371
+ provider,
372
+ `OAuth callback port ${route.port} for "${provider}" is already in use ` +
373
+ `(detected on ${host}). This is a fixed port: another application is ` +
374
+ `holding it, or a previous sign-in did not finish. Close that ` +
375
+ `application (or restart the host process), then try again.`,
376
+ { port: route.port },
377
+ );
378
+ }
379
+ }
380
+ }
269
381
 
270
- async function withOAuthCallbackPageShim<T>(
382
+ /**
383
+ * Install the callback-page shim for a flow when there is a known callback
384
+ * route AND something to do with it (a custom renderer and/or a result
385
+ * observer). Returns a release function (a no-op when no shim was installed).
386
+ *
387
+ * Unlike a `try/finally` wrapper around pi-ai's `login()`, the caller owns
388
+ * the release lifecycle: pi-ai's callback-server flows hang forever on a
389
+ * non-success callback, so cleanup must be tied to the flow's own
390
+ * race/timeout, not to awaiting the (possibly never-settling) login promise.
391
+ */
392
+ function maybeInstallOAuthCallbackShim(
271
393
  provider: string,
272
394
  providerName: string,
273
395
  render: OAuthCallbackPageRenderer | undefined,
274
- run: () => Promise<T>,
275
- ): Promise<T> {
396
+ onResult: ActiveOAuthCallbackPageShim['onResult'],
397
+ ): () => void {
276
398
  const route = OAUTH_CALLBACK_ROUTES[provider];
277
- if (!render || !route) {
278
- return run();
399
+ if (!route || (!render && !onResult)) {
400
+ return () => {};
279
401
  }
280
402
 
281
- const release = installOAuthCallbackPageShim({
403
+ return installOAuthCallbackPageShim({
282
404
  provider,
283
405
  providerName,
284
406
  route,
285
407
  render,
408
+ onResult,
286
409
  });
287
-
288
- try {
289
- return await run();
290
- } finally {
291
- release();
292
- }
293
410
  }
294
411
 
295
412
  function installOAuthCallbackPageShim(shim: ActiveOAuthCallbackPageShim): () => void {
@@ -303,6 +420,7 @@ function installOAuthCallbackPageShim(shim: ActiveOAuthCallbackPageShim): () =>
303
420
  const prototype = http.ServerResponse.prototype;
304
421
  const previousEnd = prototype.end;
305
422
  activeOAuthCallbackPageShim = shim;
423
+ oauthCallbackResultNotified = false;
306
424
 
307
425
  const patchedEnd = function patchedOAuthCallbackEnd(this: ServerResponse, ...args: unknown[]) {
308
426
  const replacement = maybeRenderOAuthCallbackPage(this, args[0]);
@@ -373,6 +491,19 @@ function maybeRenderOAuthCallbackPage(response: ServerResponse, chunk: unknown):
373
491
  context.details = details;
374
492
  }
375
493
 
494
+ // Notify the flow that the browser callback fired (once). This lets
495
+ // initiateOAuth react to a failed callback immediately rather than
496
+ // waiting on pi-ai, which hangs on non-success callbacks.
497
+ if (!oauthCallbackResultNotified && shim.onResult) {
498
+ oauthCallbackResultNotified = true;
499
+ try {
500
+ shim.onResult(status, context);
501
+ } catch {
502
+ // An observer must never break the callback response.
503
+ }
504
+ }
505
+
506
+ if (!shim.render) return null;
376
507
  try {
377
508
  const rendered = shim.render(context);
378
509
  return typeof rendered === 'string' && rendered.trim().length > 0 ? rendered : null;
@@ -639,10 +770,12 @@ function mapRawToModelInfo(
639
770
  supportsThinking: supportedThinkingLevels.some(level => level !== 'off')
640
771
  || !!(raw['supportsThinking'] || raw['reasoning']),
641
772
  supportedThinkingLevels,
642
- supportsImages: !!raw['supportsImages'],
773
+ supportsImages: Array.isArray(raw['input'])
774
+ ? raw['input'].includes('image')
775
+ : !!raw['supportsImages'],
643
776
  };
644
777
 
645
- const rawPricing = raw['pricing'];
778
+ const rawPricing = raw['pricing'] ?? raw['cost'];
646
779
  if (rawPricing && typeof rawPricing === 'object') {
647
780
  const pricing = rawPricing as Record<string, unknown>;
648
781
  const inputPrice = pricing['input'];
@@ -729,41 +862,112 @@ export class ProviderManager implements IProviderManager {
729
862
  * @param provider - OAuth provider identifier
730
863
  * @param callbacks - UI callbacks for auth URL, prompts, and progress
731
864
  * @returns The OAuth credentials and display metadata
732
- * @throws Error if the provider does not support OAuth or pi-ai is not installed
865
+ * @throws {OAuthError} `unsupported_provider`, `callback_port_in_use`,
866
+ * `cancelled`, `timed_out`, or `callback_failed`. Other errors (e.g.
867
+ * network/token-exchange failures from pi-ai) propagate as-is.
733
868
  */
734
869
  async initiateOAuth(provider: string, callbacks: OAuthCallbacks): Promise<OAuthResult> {
735
870
  const oauthModule = await loadPiAiOAuth();
736
871
  const oauthProvider = oauthModule.getOAuthProvider?.(provider);
737
872
  if (!oauthProvider) {
738
- throw new Error(`Provider "${provider}" does not support OAuth`);
873
+ throw new OAuthError(
874
+ 'unsupported_provider',
875
+ provider,
876
+ `Provider "${provider}" does not support OAuth`,
877
+ );
739
878
  }
740
879
 
741
- this.activeOAuthAbort = new AbortController();
880
+ // (A) Fail fast — before opening a browser — if the provider's fixed
881
+ // callback port is already taken. Otherwise pi-ai binds the other
882
+ // stack, the browser hits the wrong listener, and pi-ai waits forever.
883
+ await assertOAuthCallbackPortAvailable(provider);
742
884
 
743
- try {
744
- const rawCredentials = await withOAuthCallbackPageShim(
885
+ const abort = new AbortController();
886
+ this.activeOAuthAbort = abort;
887
+
888
+ // (C) pi-ai only settles its callback wait on success; on a failed
889
+ // callback (e.g. state mismatch) it hangs. The render shim already sees
890
+ // that response — use it to fail the flow immediately with the reason.
891
+ let failFromCallback!: (err: OAuthError) => void;
892
+ const callbackFailure = new Promise<never>((_, reject) => {
893
+ failFromCallback = reject;
894
+ });
895
+ const handleCallbackResult = (
896
+ status: OAuthCallbackPageStatus,
897
+ ctx: OAuthCallbackPageContext,
898
+ ): void => {
899
+ if (status !== 'error') return;
900
+ const detail = ctx.details ? ` (${ctx.details})` : '';
901
+ failFromCallback(new OAuthError(
902
+ 'callback_failed',
745
903
  provider,
746
- oauthProvider.name,
747
- callbacks.renderCallbackPage,
748
- () => oauthProvider.login({
749
- onAuth: callbacks.onAuth,
750
- onPrompt: callbacks.onPrompt,
751
- onProgress: callbacks.onProgress,
752
- onManualCodeInput: callbacks.onManualCodeInput,
753
- onSelect: callbacks.onSelect,
754
- signal: this.activeOAuthAbort!.signal,
755
- }),
756
- );
904
+ `OAuth callback for "${provider}" reported a failure: ${ctx.message}${detail}`,
905
+ ));
906
+ };
757
907
 
758
- this.activeOAuthAbort = null;
908
+ const releaseShim = maybeInstallOAuthCallbackShim(
909
+ provider,
910
+ oauthProvider.name,
911
+ callbacks.renderCallbackPage,
912
+ handleCallbackResult,
913
+ );
914
+
915
+ // (B) pi-ai callback servers ignore the abort signal, so cancellation
916
+ // and timeout are enforced here. Without this the flow hangs forever.
917
+ const timeoutMs = callbacks.timeoutMs ?? DEFAULT_OAUTH_FLOW_TIMEOUT_MS;
918
+ let timer: ReturnType<typeof setTimeout> | undefined;
919
+ const timeout = new Promise<never>((_, reject) => {
920
+ if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
921
+ timer = setTimeout(() => reject(new OAuthError(
922
+ 'timed_out',
923
+ provider,
924
+ `OAuth flow for "${provider}" timed out after ${timeoutMs}ms.`,
925
+ )), timeoutMs);
926
+ timer.unref?.();
927
+ }
928
+ });
929
+ const cancelled = new Promise<never>((_, reject) => {
930
+ abort.signal.addEventListener('abort', () => reject(new OAuthError(
931
+ 'cancelled',
932
+ provider,
933
+ `OAuth flow for "${provider}" was cancelled.`,
934
+ )), { once: true });
935
+ });
936
+
937
+ const login = oauthProvider.login({
938
+ onAuth: callbacks.onAuth,
939
+ onPrompt: callbacks.onPrompt,
940
+ onProgress: callbacks.onProgress,
941
+ onManualCodeInput: callbacks.onManualCodeInput,
942
+ onSelect: callbacks.onSelect,
943
+ signal: abort.signal,
944
+ }) as Promise<Record<string, unknown>>;
945
+ // Whichever promise loses the race may still settle later (pi-ai's
946
+ // login can hang or settle late; the aux promises can reject after the
947
+ // race is decided). Attach inert handlers so a late rejection never
948
+ // surfaces as an unhandled rejection. Promise.race still observes the
949
+ // first settlement independently.
950
+ login.catch(() => {});
951
+ cancelled.catch(() => {});
952
+ timeout.catch(() => {});
953
+ callbackFailure.catch(() => {});
954
+
955
+ try {
956
+ const rawCredentials = await Promise.race([
957
+ login,
958
+ cancelled,
959
+ timeout,
960
+ callbackFailure,
961
+ ]);
759
962
 
760
963
  const credentials = JSON.stringify(rawCredentials);
761
964
  const meta = buildOAuthMeta(provider, rawCredentials);
762
965
 
763
966
  return { credentials, meta };
764
- } catch (err) {
765
- this.activeOAuthAbort = null;
766
- throw err;
967
+ } finally {
968
+ if (timer) clearTimeout(timer);
969
+ releaseShim();
970
+ if (this.activeOAuthAbort === abort) this.activeOAuthAbort = null;
767
971
  }
768
972
  }
769
973
 
@@ -830,32 +1034,31 @@ export class ProviderManager implements IProviderManager {
830
1034
  async validateApiKey(provider: string, apiKey: string): Promise<ApiKeyValidationResult> {
831
1035
  const piAi = await loadPiAi();
832
1036
 
833
- // Find the cheapest model for this provider to minimize validation cost
834
- const cheapestModelId = this.getSmallestModelId(provider);
835
- if (!cheapestModelId) {
836
- // No known model, try a generic test with the provider's first model
837
- const models = piAi.getModels(provider);
838
- if (models.length === 0) {
839
- return {
840
- provider,
841
- modelId: null,
842
- valid: false,
843
- retryable: false,
844
- status: 'resolution_error',
845
- message: `No models found for provider "${provider}"`,
846
- };
847
- }
848
- const firstRawId = models[0]!['id'];
849
- const firstRawName = models[0]!['name'];
850
- const firstModelId = typeof firstRawId === 'string'
851
- ? firstRawId
852
- : typeof firstRawName === 'string'
853
- ? firstRawName
854
- : String(firstRawId ?? firstRawName);
855
- return this.tryValidation(piAi, provider, firstModelId, apiKey);
1037
+ const models = piAi.getModels(provider) ?? [];
1038
+ if (models.length === 0) {
1039
+ return {
1040
+ provider,
1041
+ modelId: null,
1042
+ valid: false,
1043
+ retryable: false,
1044
+ status: 'resolution_error',
1045
+ message: `No models found for provider "${provider}"`,
1046
+ };
1047
+ }
1048
+
1049
+ const modelId = this.getSmallestModelId(provider, models);
1050
+ if (!modelId) {
1051
+ return {
1052
+ provider,
1053
+ modelId: null,
1054
+ valid: false,
1055
+ retryable: false,
1056
+ status: 'resolution_error',
1057
+ message: `No usable models found for provider "${provider}"`,
1058
+ };
856
1059
  }
857
1060
 
858
- return this.tryValidation(piAi, provider, cheapestModelId, apiKey);
1061
+ return this.tryValidation(piAi, provider, modelId, apiKey);
859
1062
  }
860
1063
 
861
1064
  /**
@@ -948,11 +1151,10 @@ export class ProviderManager implements IProviderManager {
948
1151
  // -----------------------------------------------------------------------
949
1152
 
950
1153
  /**
951
- * Get the cheapest known model ID for a provider.
952
- * Uses the UTILITY_MODEL_DEFAULTS as a proxy for "smallest model."
1154
+ * Get the cheapest likely utility model ID for a provider.
953
1155
  */
954
- private getSmallestModelId(provider: string): string | null {
955
- return UTILITY_MODEL_DEFAULTS[provider] ?? null;
1156
+ private getSmallestModelId(provider: string, models: Array<Record<string, unknown>>): string | null {
1157
+ return UTILITY_MODEL_OVERRIDES[provider] ?? inferUtilityModelId(models);
956
1158
  }
957
1159
 
958
1160
  /**
@@ -4,7 +4,7 @@
4
4
  * This module contains:
5
5
  * 1. PROVIDER_REGISTRY: metadata for all known providers (auth methods, env vars, key prefixes)
6
6
  * 2. OAUTH_PROVIDER_IDS: the subset of providers that support OAuth
7
- * 3. UTILITY_MODEL_DEFAULTS: per-provider cheapest-capable model for utility operations
7
+ * 3. UTILITY_MODEL_OVERRIDES: per-provider utility model overrides for inference exceptions
8
8
  *
9
9
  * OAuth flows are resolved through pi-ai's OAuth provider registry at runtime.
10
10
  *
@@ -270,17 +270,9 @@ export const OAUTH_PROVIDER_IDS: string[] = [
270
270
  ];
271
271
 
272
272
  // ---------------------------------------------------------------------------
273
- // Utility Model Defaults
273
+ // Model Defaults
274
274
  // ---------------------------------------------------------------------------
275
275
 
276
- /**
277
- * Default utility model IDs per provider.
278
- * Used when utilityModel is 'default' or undefined.
279
- *
280
- * These are the cheapest capable models for each provider,
281
- * suitable for internal operations like WebFetch summarization
282
- * and safety classification.
283
- */
284
276
  /**
285
277
  * Default primary model IDs per provider.
286
278
  * Used when a user first connects a provider and no model is explicitly selected.
@@ -289,21 +281,22 @@ export const OAUTH_PROVIDER_IDS: string[] = [
289
281
  export const PRIMARY_MODEL_DEFAULTS: Record<string, string> = {
290
282
  anthropic: 'claude-sonnet-4-6',
291
283
  openai: 'gpt-5.4',
284
+ 'openai-codex': 'gpt-5.5',
292
285
  google: 'gemini-3.1-pro-preview',
286
+ xai: 'grok-4',
293
287
  groq: 'openai/gpt-oss-120b',
294
288
  cerebras: 'gpt-oss-120b',
295
289
  mistral: 'mistral-large-2512',
296
290
  };
297
291
 
298
- export const UTILITY_MODEL_DEFAULTS: Record<string, string> = {
299
- anthropic: 'claude-haiku-4-5-20251001', // $1.00/$5.00 per 1M tokens
300
- openai: 'gpt-4.1-nano', // $0.10/$0.40 per 1M tokens
301
- 'openai-codex': 'gpt-5.4-mini', // Current small Codex-capable model
302
- google: 'gemini-2.5-flash-lite', // $0.10/$0.40 per 1M tokens
303
- groq: 'llama-3.1-8b-instant', // ~$0.05/$0.08 per 1M tokens
304
- cerebras: 'llama3.1-8b', // ~$0.10/$0.10 per 1M tokens
305
- mistral: 'mistral-small-2506', // $0.06/$0.18 per 1M tokens
306
- };
292
+ /**
293
+ * Per-provider utility model overrides for inference exceptions.
294
+ * Leave empty unless dynamic inference picks a bad utility model for a provider.
295
+ */
296
+ export const UTILITY_MODEL_OVERRIDES: Record<string, string> = {};
297
+
298
+ /** Backwards-compatible alias. Prefer UTILITY_MODEL_OVERRIDES for new code. */
299
+ export const UTILITY_MODEL_DEFAULTS = UTILITY_MODEL_OVERRIDES;
307
300
 
308
301
  // ---------------------------------------------------------------------------
309
302
  // Cache Retention