@gajae-code/coding-agent 0.5.1 → 0.5.2

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/setup-cli.d.ts +8 -1
  4. package/dist/types/commands/setup.d.ts +7 -0
  5. package/dist/types/config/file-lock.d.ts +24 -2
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/models-config-schema.d.ts +5 -0
  8. package/dist/types/config/settings-schema.d.ts +62 -0
  9. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  12. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  13. package/dist/types/modes/interactive-mode.d.ts +1 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  15. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  16. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  17. package/dist/types/modes/theme/theme.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/session/history-storage.d.ts +2 -2
  20. package/dist/types/session/session-manager.d.ts +10 -1
  21. package/dist/types/setup/credential-import.d.ts +79 -0
  22. package/dist/types/task/executor.d.ts +1 -0
  23. package/dist/types/task/render.d.ts +1 -1
  24. package/dist/types/tools/subagent-render.d.ts +7 -1
  25. package/dist/types/tools/subagent.d.ts +21 -0
  26. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  27. package/dist/types/web/search/index.d.ts +4 -4
  28. package/dist/types/web/search/provider.d.ts +16 -20
  29. package/dist/types/web/search/providers/base.d.ts +2 -1
  30. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  31. package/dist/types/web/search/types.d.ts +14 -2
  32. package/package.json +7 -7
  33. package/scripts/build-binary.ts +7 -0
  34. package/src/cli/args.ts +2 -0
  35. package/src/cli/fast-help.ts +2 -0
  36. package/src/cli/setup-cli.ts +138 -3
  37. package/src/commands/setup.ts +5 -1
  38. package/src/commands/ultragoal.ts +3 -1
  39. package/src/config/file-lock-gc.ts +14 -2
  40. package/src/config/file-lock.ts +54 -12
  41. package/src/config/model-profile-activation.ts +15 -3
  42. package/src/config/model-profiles.ts +15 -15
  43. package/src/config/model-registry.ts +21 -1
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +62 -0
  46. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  47. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  48. package/src/gjc-runtime/launch-tmux.ts +3 -4
  49. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  50. package/src/gjc-runtime/state-runtime.ts +2 -1
  51. package/src/gjc-runtime/state-writer.ts +254 -7
  52. package/src/gjc-runtime/tmux-gc.ts +2 -1
  53. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  54. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  55. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  56. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  57. package/src/harness-control-plane/owner.ts +3 -2
  58. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  59. package/src/hooks/skill-state.ts +121 -2
  60. package/src/internal-urls/docs-index.generated.ts +13 -9
  61. package/src/lsp/defaults.json +1 -0
  62. package/src/main.ts +14 -4
  63. package/src/modes/acp/acp-agent.ts +4 -2
  64. package/src/modes/bridge/bridge-mode.ts +2 -1
  65. package/src/modes/components/history-search.ts +5 -2
  66. package/src/modes/components/model-selector.ts +26 -0
  67. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  68. package/src/modes/controllers/selector-controller.ts +80 -1
  69. package/src/modes/interactive-mode.ts +11 -1
  70. package/src/modes/rpc/rpc-mode.ts +132 -18
  71. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  72. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  73. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  74. package/src/modes/theme/defaults/claude-code.json +100 -0
  75. package/src/modes/theme/defaults/codex.json +100 -0
  76. package/src/modes/theme/defaults/index.ts +6 -0
  77. package/src/modes/theme/defaults/opencode.json +102 -0
  78. package/src/modes/theme/theme.ts +2 -2
  79. package/src/modes/types.ts +1 -1
  80. package/src/prompts/agents/executor.md +5 -2
  81. package/src/sdk.ts +12 -1
  82. package/src/session/agent-session.ts +22 -11
  83. package/src/session/history-storage.ts +32 -11
  84. package/src/session/session-manager.ts +70 -18
  85. package/src/setup/credential-import.ts +429 -0
  86. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  87. package/src/task/executor.ts +7 -1
  88. package/src/task/render.ts +18 -7
  89. package/src/tools/ask.ts +4 -2
  90. package/src/tools/cron.ts +1 -1
  91. package/src/tools/subagent-render.ts +119 -29
  92. package/src/tools/subagent.ts +147 -7
  93. package/src/tools/ultragoal-ask-guard.ts +39 -0
  94. package/src/web/search/index.ts +25 -25
  95. package/src/web/search/provider.ts +178 -87
  96. package/src/web/search/providers/base.ts +2 -1
  97. package/src/web/search/providers/openai-compatible.ts +151 -0
  98. package/src/web/search/types.ts +47 -22
@@ -10,7 +10,8 @@
10
10
 
11
11
  import type { AuthStorage } from "@gajae-code/ai";
12
12
  import type { SearchProvider } from "./providers/base";
13
- import type { SearchProviderId } from "./types";
13
+ import type { ActiveSearchModelContext, SearchProviderId } from "./types";
14
+ import { isConfigurableSearchProviderId } from "./types";
14
15
 
15
16
  export type { SearchParams } from "./providers/base";
16
17
  export { SearchProvider } from "./providers/base";
@@ -23,36 +24,16 @@ interface ProviderMeta {
23
24
 
24
25
  /** Lazy factories. Each `load()` dynamic-imports its provider module on first call. */
25
26
  const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
26
- exa: {
27
- id: "exa",
28
- label: "Exa",
29
- load: async () => new (await import("./providers/exa")).ExaProvider(),
30
- },
31
- brave: {
32
- id: "brave",
33
- label: "Brave",
34
- load: async () => new (await import("./providers/brave")).BraveProvider(),
35
- },
36
- jina: {
37
- id: "jina",
38
- label: "Jina",
39
- load: async () => new (await import("./providers/jina")).JinaProvider(),
40
- },
27
+ exa: { id: "exa", label: "Exa", load: async () => new (await import("./providers/exa")).ExaProvider() },
28
+ brave: { id: "brave", label: "Brave", load: async () => new (await import("./providers/brave")).BraveProvider() },
29
+ jina: { id: "jina", label: "Jina", load: async () => new (await import("./providers/jina")).JinaProvider() },
41
30
  perplexity: {
42
31
  id: "perplexity",
43
32
  label: "Perplexity",
44
33
  load: async () => new (await import("./providers/perplexity")).PerplexityProvider(),
45
34
  },
46
- kimi: {
47
- id: "kimi",
48
- label: "Kimi",
49
- load: async () => new (await import("./providers/kimi")).KimiProvider(),
50
- },
51
- zai: {
52
- id: "zai",
53
- label: "Z.AI",
54
- load: async () => new (await import("./providers/zai")).ZaiProvider(),
55
- },
35
+ kimi: { id: "kimi", label: "Kimi", load: async () => new (await import("./providers/kimi")).KimiProvider() },
36
+ zai: { id: "zai", label: "Z.AI", load: async () => new (await import("./providers/zai")).ZaiProvider() },
56
37
  anthropic: {
57
38
  id: "anthropic",
58
39
  label: "Anthropic",
@@ -63,11 +44,7 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
63
44
  label: "Gemini",
64
45
  load: async () => new (await import("./providers/gemini")).GeminiProvider(),
65
46
  },
66
- codex: {
67
- id: "codex",
68
- label: "OpenAI",
69
- load: async () => new (await import("./providers/codex")).CodexProvider(),
70
- },
47
+ codex: { id: "codex", label: "OpenAI", load: async () => new (await import("./providers/codex")).CodexProvider() },
71
48
  tavily: {
72
49
  id: "tavily",
73
50
  label: "Tavily",
@@ -78,11 +55,7 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
78
55
  label: "Parallel",
79
56
  load: async () => new (await import("./providers/parallel")).ParallelProvider(),
80
57
  },
81
- kagi: {
82
- id: "kagi",
83
- label: "Kagi",
84
- load: async () => new (await import("./providers/kagi")).KagiProvider(),
85
- },
58
+ kagi: { id: "kagi", label: "Kagi", load: async () => new (await import("./providers/kagi")).KagiProvider() },
86
59
  synthetic: {
87
60
  id: "synthetic",
88
61
  label: "Synthetic",
@@ -98,26 +71,24 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
98
71
  label: "DuckDuckGo",
99
72
  load: async () => new (await import("./providers/duckduckgo")).DuckDuckGoProvider(),
100
73
  },
74
+ "openai-compatible": {
75
+ id: "openai-compatible",
76
+ label: "OpenAI-compatible",
77
+ load: async () => new (await import("./providers/openai-compatible")).OpenAICompatibleSearchProvider(),
78
+ },
101
79
  };
102
80
 
103
81
  const instanceCache = new Map<SearchProviderId, SearchProvider>();
104
82
 
105
- /** Cheap, sync metadata accessor — never triggers a provider load. */
106
83
  export function getSearchProviderLabel(id: SearchProviderId): string {
107
84
  return PROVIDER_META[id]?.label ?? id;
108
85
  }
109
86
 
110
- /**
111
- * Resolve and cache a provider instance. First call for a given id loads the
112
- * underlying module; subsequent calls return the cached singleton.
113
- */
114
87
  export async function getSearchProvider(id: SearchProviderId): Promise<SearchProvider> {
115
88
  const cached = instanceCache.get(id);
116
89
  if (cached) return cached;
117
90
  const meta = PROVIDER_META[id];
118
- if (!meta) {
119
- throw new Error(`Unknown search provider: ${id}`);
120
- }
91
+ if (!meta) throw new Error(`Unknown search provider: ${id}`);
121
92
  const provider = await meta.load();
122
93
  instanceCache.set(id, provider);
123
94
  return provider;
@@ -141,13 +112,6 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
141
112
  "searxng",
142
113
  ];
143
114
 
144
- /**
145
- * Map an active model's provider string to its own native web-search provider.
146
- * Keys are real model provider ids (see packages/ai/src/types.ts KnownProvider);
147
- * a few aliases (gemini/kimi) and API strings (openai-responses) are tolerated
148
- * defensively. Providers absent from this map (custom/unknown) fall through to
149
- * DuckDuckGo.
150
- */
151
115
  const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
152
116
  openai: "codex",
153
117
  "openai-codex": "codex",
@@ -165,54 +129,181 @@ const MODEL_PROVIDER_TO_SEARCH: Record<string, SearchProviderId> = {
165
129
  synthetic: "synthetic",
166
130
  };
167
131
 
168
- /** Preferred provider set via settings (default: auto) */
169
132
  let preferredProvId: SearchProviderId | "auto" = "auto";
133
+ let fallbackProvIds: SearchProviderId[] = [];
170
134
 
171
- /** Set the preferred web search provider from settings */
172
135
  export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
173
136
  preferredProvId = provider;
174
137
  }
175
138
 
176
- /**
177
- * Resolve the ordered provider chain for a search request.
178
- *
179
- * Resolution is active-model-gated, never credential-scanning:
180
- * 1. An explicitly preferred provider (settings) that is available is primary.
181
- * 2. Otherwise the active model's own native search is primary, but only when
182
- * that provider's own credentials are present (its `isAvailable()`).
183
- * 3. DuckDuckGo (keyless) is always appended as the terminal fallback, so a
184
- * missing primary — or a primary runtime failure — still returns results
185
- * with zero configuration. Keyed standalone providers are never
186
- * auto-selected; they are reachable only via explicit selection (step 1).
187
- */
188
- export async function resolveProviderChain(
139
+ export function setSearchFallbackProviders(ids: readonly string[]): void {
140
+ fallbackProvIds = ids.filter(isConfigurableSearchProviderId);
141
+ }
142
+
143
+ export interface ResolveProviderChainOptions {
144
+ authStorage: AuthStorage;
145
+ sessionId?: string;
146
+ signal?: AbortSignal;
147
+ preferredProvider?: SearchProviderId | "auto";
148
+ activeModelContext?: ActiveSearchModelContext;
149
+ fallbackProviders?: readonly SearchProviderId[];
150
+ }
151
+
152
+ async function appendAvailable(
153
+ chain: SearchProviderId[],
154
+ id: SearchProviderId,
155
+ authStorage: AuthStorage,
156
+ ): Promise<void> {
157
+ if (chain.includes(id)) return;
158
+ const provider = await getSearchProvider(id);
159
+ if (await provider.isAvailable(authStorage)) chain.push(id);
160
+ }
161
+
162
+ function appendDeduped(chain: SearchProviderId[], id: SearchProviderId): void {
163
+ if (!chain.includes(id)) chain.push(id);
164
+ }
165
+
166
+ function isAnthropicWire(api: string): boolean {
167
+ return api === "anthropic-messages";
168
+ }
169
+
170
+ function isGoogleWire(api: string): boolean {
171
+ return api === "google-generative-ai" || api === "google-vertex" || api === "google-gemini-cli";
172
+ }
173
+
174
+ function isOpenAICompatWire(api: string): boolean {
175
+ return api === "openai-responses" || api === "openai-completions" || api === "azure-openai-responses";
176
+ }
177
+
178
+ export function looksHostedModelId(modelId: string | undefined): boolean {
179
+ if (!modelId) return false;
180
+ const id = modelId.toLowerCase();
181
+ return /^(gpt-|o\d|o-|chatgpt-|text-|davinci|babbage|curie)/.test(id);
182
+ }
183
+
184
+ function looksOpenAIFamilyModelId(ctx: ActiveSearchModelContext): boolean {
185
+ return looksHostedModelId(ctx.wireModelId) || looksHostedModelId(ctx.modelId);
186
+ }
187
+
188
+ export function isLocalBaseUrl(baseUrl: string | undefined): boolean {
189
+ if (!baseUrl) return false;
190
+ let url: URL;
191
+ try {
192
+ url = new URL(baseUrl);
193
+ } catch {
194
+ return true;
195
+ }
196
+ const host = url.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
197
+ if (
198
+ host === "localhost" ||
199
+ host.endsWith(".localhost") ||
200
+ host === "host.docker.internal" ||
201
+ host.endsWith(".local")
202
+ )
203
+ return true;
204
+ const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
205
+ if (v4) {
206
+ const [a, b] = v4.slice(1, 3).map(Number);
207
+ if (
208
+ a === 127 ||
209
+ a === 0 ||
210
+ a === 10 ||
211
+ (a === 172 && b >= 16 && b <= 31) ||
212
+ (a === 192 && b === 168) ||
213
+ (a === 169 && b === 254)
214
+ )
215
+ return true;
216
+ }
217
+ if (host === "::1" || host === "::") return true;
218
+ if (host.startsWith("fc") || host.startsWith("fd")) return true;
219
+ if (host.startsWith("fe8") || host.startsWith("fe9") || host.startsWith("fea") || host.startsWith("feb"))
220
+ return true;
221
+ return false;
222
+ }
223
+
224
+ export function inferNativeProviderFromModel(ctx: ActiveSearchModelContext | undefined): SearchProviderId | undefined {
225
+ if (!ctx || ctx.webSearch === "off") return undefined;
226
+ const modelId = (ctx.wireModelId ?? ctx.modelId).toLowerCase();
227
+ if (modelId.startsWith("claude-") && isAnthropicWire(ctx.api)) return "anthropic";
228
+ if (modelId.startsWith("gemini-") && isGoogleWire(ctx.api)) return "gemini";
229
+ if (looksOpenAIFamilyModelId(ctx) && isOpenAICompatWire(ctx.api)) {
230
+ if (ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl)) return "codex";
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ function canUseDirectProviderMapping(ctx: ActiveSearchModelContext, id: SearchProviderId): boolean {
236
+ if (ctx.webSearch === "off") return false;
237
+ if (id !== "codex") return true;
238
+ if (!isOpenAICompatWire(ctx.api)) return true;
239
+ return ctx.webSearch === "on" || !isLocalBaseUrl(ctx.baseUrl);
240
+ }
241
+
242
+ export async function canUseGenericCredentials(
189
243
  authStorage: AuthStorage,
190
- preferredProvider: SearchProviderId | "auto" = preferredProvId,
191
- activeModelProvider?: string,
192
- ): Promise<SearchProvider[]> {
244
+ ctx: ActiveSearchModelContext | undefined,
245
+ sessionId?: string,
246
+ signal?: AbortSignal,
247
+ ): Promise<boolean> {
248
+ if (!ctx) return false;
249
+ const key = await authStorage.getApiKey(ctx.provider, sessionId, {
250
+ baseUrl: ctx.baseUrl,
251
+ modelId: ctx.modelId,
252
+ signal,
253
+ });
254
+ return Boolean(key);
255
+ }
256
+
257
+ export async function shouldTryGenericOpenAICompat(
258
+ authStorage: AuthStorage,
259
+ ctx: ActiveSearchModelContext | undefined,
260
+ sessionId?: string,
261
+ signal?: AbortSignal,
262
+ ): Promise<boolean> {
263
+ if (!ctx || ctx.webSearch === "off" || !isOpenAICompatWire(ctx.api)) return false;
264
+ const autoAllowed =
265
+ ctx.webSearch === "on" ||
266
+ ((ctx.api === "openai-responses" || looksOpenAIFamilyModelId(ctx)) && !isLocalBaseUrl(ctx.baseUrl));
267
+ return autoAllowed && (await canUseGenericCredentials(authStorage, ctx, sessionId, signal));
268
+ }
269
+
270
+ export async function resolveProviderChain(options: ResolveProviderChainOptions): Promise<SearchProvider[]> {
271
+ const {
272
+ authStorage,
273
+ sessionId,
274
+ signal,
275
+ preferredProvider = preferredProvId,
276
+ activeModelContext,
277
+ fallbackProviders = fallbackProvIds,
278
+ } = options;
193
279
  const chain: SearchProviderId[] = [];
194
280
 
195
- if (preferredProvider !== "auto") {
196
- const provider = await getSearchProvider(preferredProvider);
197
- if (await provider.isAvailable(authStorage)) {
198
- chain.push(preferredProvider);
199
- }
200
- } else if (activeModelProvider) {
201
- const nativeId = MODEL_PROVIDER_TO_SEARCH[activeModelProvider.toLowerCase()];
202
- if (nativeId) {
203
- const provider = await getSearchProvider(nativeId);
204
- if (await provider.isAvailable(authStorage)) {
205
- chain.push(nativeId);
206
- }
207
- }
281
+ // A forced primary is honored only when it is a user-configurable provider.
282
+ // The internal `openai-compatible` adapter (and any non-configurable value) is
283
+ // never selectable as a forced primary; such inputs fall through to auto
284
+ // native resolution instead of being injected into the chain.
285
+ if (preferredProvider !== "auto" && isConfigurableSearchProviderId(preferredProvider)) {
286
+ await appendAvailable(chain, preferredProvider, authStorage);
287
+ } else if (activeModelContext) {
288
+ const directId = MODEL_PROVIDER_TO_SEARCH[activeModelContext.provider.toLowerCase()];
289
+ if (directId && canUseDirectProviderMapping(activeModelContext, directId))
290
+ await appendAvailable(chain, directId, authStorage);
291
+ const inferred = inferNativeProviderFromModel(activeModelContext);
292
+ if (inferred) await appendAvailable(chain, inferred, authStorage);
293
+ if (await shouldTryGenericOpenAICompat(authStorage, activeModelContext, sessionId, signal))
294
+ appendDeduped(chain, "openai-compatible");
208
295
  }
209
296
 
210
- // DuckDuckGo is the permissionless terminal fallback (deduped).
211
- if (!chain.includes("duckduckgo")) chain.push("duckduckgo");
297
+ // Configured fallbacks are user-facing only: the internal `openai-compatible`
298
+ // adapter (and any non-configurable id) can never enter the chain through the
299
+ // fallback list, regardless of how `fallbackProviders` was supplied.
300
+ for (const id of fallbackProviders) {
301
+ if (!isConfigurableSearchProviderId(id)) continue;
302
+ await appendAvailable(chain, id, authStorage);
303
+ }
304
+ appendDeduped(chain, "duckduckgo");
212
305
 
213
306
  const providers: SearchProvider[] = [];
214
- for (const id of chain) {
215
- providers.push(await getSearchProvider(id));
216
- }
307
+ for (const id of chain) providers.push(await getSearchProvider(id));
217
308
  return providers;
218
309
  }
@@ -1,5 +1,5 @@
1
1
  import type { AuthStorage } from "@gajae-code/ai";
2
- import type { SearchProviderId, SearchResponse } from "../types";
2
+ import type { ActiveSearchModelContext, SearchProviderId, SearchResponse } from "../types";
3
3
 
4
4
  /**
5
5
  * Shared web search parameters passed to providers.
@@ -50,6 +50,7 @@ export interface SearchParams {
50
50
  * caller's agent session when available; otherwise omit.
51
51
  */
52
52
  sessionId?: string;
53
+ activeModelContext?: ActiveSearchModelContext;
53
54
  }
54
55
 
55
56
  /** Base class for web search providers. */
@@ -0,0 +1,151 @@
1
+ import type { SearchCitation, SearchResponse, SearchSource } from "../types";
2
+ import { SearchProviderError } from "../types";
3
+ import type { SearchParams } from "./base";
4
+ import { SearchProvider } from "./base";
5
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
6
+
7
+ function endpoint(baseUrl: string, api: string): string {
8
+ const base = baseUrl.replace(/\/+$/, "");
9
+ return api === "openai-completions" ? `${base}/chat/completions` : `${base}/responses`;
10
+ }
11
+
12
+ function textFromResponse(json: any): string | undefined {
13
+ if (typeof json.output_text === "string") return json.output_text;
14
+ const chunks: string[] = [];
15
+ for (const item of json.output ?? []) {
16
+ for (const content of item.content ?? []) {
17
+ if (typeof content.text === "string") chunks.push(content.text);
18
+ }
19
+ }
20
+ const chat = json.choices?.[0]?.message?.content;
21
+ if (typeof chat === "string") chunks.push(chat);
22
+ return chunks.join("\n") || undefined;
23
+ }
24
+
25
+ function pushCitation(out: SearchCitation[], rawUrl: unknown, rawTitle: unknown, rawText: unknown): void {
26
+ if (typeof rawUrl !== "string" || !rawUrl) return;
27
+ out.push({
28
+ url: rawUrl,
29
+ title: typeof rawTitle === "string" && rawTitle ? rawTitle : rawUrl,
30
+ citedText: typeof rawText === "string" ? rawText : undefined,
31
+ });
32
+ }
33
+
34
+ // Only recognized grounding annotations count as citations. An OpenAI-compatible
35
+ // endpoint that ignores the web_search request returns a normal answer with no
36
+ // `url_citation` annotations; treating arbitrary URL/`type:"source"` metadata as a
37
+ // citation would mask that non-search answer as a real search result. Restrict
38
+ // extraction to the documented annotation shapes (Responses
39
+ // `output[].content[].annotations[]` and Chat `choices[].message.annotations[]`),
40
+ // accepting only `type: "url_citation"` entries.
41
+ function collectCitationAnnotations(annotations: unknown, out: SearchCitation[]): void {
42
+ if (!Array.isArray(annotations)) return;
43
+ for (const annotation of annotations) {
44
+ if (!annotation || typeof annotation !== "object") continue;
45
+ const ann = annotation as Record<string, any>;
46
+ if (ann.type !== "url_citation") continue;
47
+ const cite =
48
+ ann.url_citation && typeof ann.url_citation === "object" ? (ann.url_citation as Record<string, any>) : ann;
49
+ pushCitation(out, cite.url ?? cite.uri, cite.title, cite.text ?? cite.quote ?? ann.text);
50
+ }
51
+ }
52
+
53
+ function parseCitations(json: any): SearchCitation[] {
54
+ const citations: SearchCitation[] = [];
55
+ for (const item of json?.output ?? []) {
56
+ for (const content of item?.content ?? []) {
57
+ collectCitationAnnotations(content?.annotations, citations);
58
+ }
59
+ }
60
+ for (const choice of json?.choices ?? []) {
61
+ collectCitationAnnotations(choice?.message?.annotations, citations);
62
+ }
63
+ const seen = new Set<string>();
64
+ return citations.filter(c => {
65
+ if (seen.has(c.url)) return false;
66
+ seen.add(c.url);
67
+ return true;
68
+ });
69
+ }
70
+
71
+ function toSources(citations: SearchCitation[], limit: number): SearchSource[] {
72
+ return citations.slice(0, limit).map(c => ({ title: c.title || c.url, url: c.url, snippet: c.citedText }));
73
+ }
74
+
75
+ export class OpenAICompatibleSearchProvider extends SearchProvider {
76
+ readonly id = "openai-compatible" as const;
77
+ readonly label = "OpenAI-compatible";
78
+
79
+ isAvailable(): boolean {
80
+ return true;
81
+ }
82
+
83
+ async search(params: SearchParams): Promise<SearchResponse> {
84
+ const ctx = params.activeModelContext;
85
+ if (!ctx)
86
+ throw new SearchProviderError(this.id, "OpenAI-compatible web search requires active model context", 400);
87
+ if (ctx.api !== "openai-responses" && ctx.api !== "openai-completions") {
88
+ throw new SearchProviderError(this.id, `OpenAI-compatible web search does not support ${ctx.api}`, 400);
89
+ }
90
+ const apiKey = await params.authStorage.getApiKey(ctx.provider, params.sessionId, {
91
+ baseUrl: ctx.baseUrl,
92
+ modelId: ctx.modelId,
93
+ signal: params.signal,
94
+ });
95
+ if (!apiKey) throw new SearchProviderError(this.id, `No credentials for ${ctx.provider}`, 401);
96
+ const model = ctx.wireModelId ?? ctx.modelId;
97
+ const headers = { ...(ctx.headers ?? {}), Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
98
+ const body =
99
+ ctx.api === "openai-completions"
100
+ ? {
101
+ model,
102
+ messages: [
103
+ { role: "system", content: params.systemPrompt },
104
+ { role: "user", content: params.query },
105
+ ],
106
+ web_search_options: {},
107
+ temperature: params.temperature,
108
+ max_tokens: params.maxOutputTokens,
109
+ }
110
+ : {
111
+ model,
112
+ input: [
113
+ { role: "system", content: params.systemPrompt },
114
+ { role: "user", content: params.query },
115
+ ],
116
+ tools: [{ type: "web_search" }],
117
+ temperature: params.temperature,
118
+ max_output_tokens: params.maxOutputTokens,
119
+ };
120
+ const response = await fetch(endpoint(ctx.baseUrl ?? "", ctx.api), {
121
+ method: "POST",
122
+ headers,
123
+ body: JSON.stringify(body),
124
+ signal: withHardTimeout(params.signal),
125
+ });
126
+ const text = await response.text();
127
+ if (!response.ok) {
128
+ const classified = classifyProviderHttpError(this.id, response.status, text);
129
+ if (classified) throw classified;
130
+ throw new SearchProviderError(
131
+ this.id,
132
+ `OpenAI-compatible web search error (${response.status}): ${text}`,
133
+ response.status,
134
+ );
135
+ }
136
+ const json = text ? JSON.parse(text) : {};
137
+ const citations = parseCitations(json);
138
+ if (citations.length === 0) {
139
+ throw new SearchProviderError(this.id, "OpenAI-compatible web search returned no citations", 424);
140
+ }
141
+ return {
142
+ provider: this.id,
143
+ answer: textFromResponse(json),
144
+ sources: toSources(citations, params.limit ?? params.numSearchResults ?? 10),
145
+ citations,
146
+ model,
147
+ requestId: json.id,
148
+ authMode: "api-key",
149
+ };
150
+ }
151
+ }
@@ -20,30 +20,55 @@ export type SearchProviderId =
20
20
  | "parallel"
21
21
  | "kagi"
22
22
  | "synthetic"
23
- | "searxng";
23
+ | "searxng"
24
+ | "openai-compatible";
25
+
26
+ export type WebSearchMode = "on" | "off" | "auto";
27
+
28
+ export interface ActiveSearchModelContext {
29
+ provider: string;
30
+ modelId: string;
31
+ wireModelId?: string;
32
+ api: string;
33
+ baseUrl?: string;
34
+ headers?: Record<string, string>;
35
+ webSearch?: WebSearchMode;
36
+ }
37
+
38
+ export const CONFIGURABLE_SEARCH_PROVIDER_IDS = [
39
+ "duckduckgo",
40
+ "exa",
41
+ "brave",
42
+ "jina",
43
+ "kimi",
44
+ "zai",
45
+ "anthropic",
46
+ "perplexity",
47
+ "gemini",
48
+ "codex",
49
+ "tavily",
50
+ "parallel",
51
+ "kagi",
52
+ "synthetic",
53
+ "searxng",
54
+ ] as const satisfies readonly SearchProviderId[];
55
+
56
+ const SEARCH_PROVIDER_IDS = [...CONFIGURABLE_SEARCH_PROVIDER_IDS, "openai-compatible"] as const;
24
57
 
25
58
  export function isSearchProviderId(value: string): value is SearchProviderId {
26
- return [
27
- "duckduckgo",
28
- "exa",
29
- "brave",
30
- "jina",
31
- "kimi",
32
- "zai",
33
- "anthropic",
34
- "perplexity",
35
- "gemini",
36
- "codex",
37
- "tavily",
38
- "parallel",
39
- "kagi",
40
- "synthetic",
41
- "searxng",
42
- ].includes(value);
43
- }
44
-
45
- export function isSearchProviderPreference(value: string): value is SearchProviderId | "auto" {
46
- return value === "auto" || isSearchProviderId(value);
59
+ return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value);
60
+ }
61
+
62
+ export function isConfigurableSearchProviderId(
63
+ value: string,
64
+ ): value is (typeof CONFIGURABLE_SEARCH_PROVIDER_IDS)[number] {
65
+ return (CONFIGURABLE_SEARCH_PROVIDER_IDS as readonly string[]).includes(value);
66
+ }
67
+
68
+ export function isSearchProviderPreference(
69
+ value: string,
70
+ ): value is (typeof CONFIGURABLE_SEARCH_PROVIDER_IDS)[number] | "auto" {
71
+ return value === "auto" || isConfigurableSearchProviderId(value);
47
72
  }
48
73
 
49
74
  /** Source returned by search (all providers) */