@bitkyc08/opencodex 2.45.0 → 2.46.0-preview.20260907

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 (52) hide show
  1. package/gui/dist/assets/{index-J96sug5C.css → index-BFgUC17B.css} +1 -1
  2. package/gui/dist/assets/{index-CCfD72yq.js → index-NcAVXkST.js} +19 -19
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/raycast.svg +3 -0
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +8 -3
  7. package/src/adapters/openai-responses.ts +7 -0
  8. package/src/bridge.ts +27 -7
  9. package/src/claude/inbound.ts +23 -5
  10. package/src/claude/outbound.ts +87 -19
  11. package/src/cli/capabilities.ts +4 -1
  12. package/src/cli/dispatch.ts +1 -1
  13. package/src/cli/doctor.ts +2 -2
  14. package/src/cli/export-command.ts +22 -10
  15. package/src/cli/help.ts +1 -1
  16. package/src/cli/index.ts +34 -7
  17. package/src/cli/integrations.ts +34 -1
  18. package/src/cli/provider.ts +27 -15
  19. package/src/cli/registry.ts +2 -2
  20. package/src/cli/version-skew.ts +36 -3
  21. package/src/clients/aside-profiles.ts +8 -7
  22. package/src/clients/config-export/contracts.ts +2 -1
  23. package/src/clients/config-export/raycast.ts +106 -0
  24. package/src/clients/config-export.ts +36 -0
  25. package/src/clients/model-presentation.ts +61 -0
  26. package/src/codex/catalog/sync.ts +44 -2
  27. package/src/codex/convergence.ts +7 -0
  28. package/src/generated/compatibility-version.json +59 -43
  29. package/src/generated/model-metadata.ts +1 -0
  30. package/src/images/loop.ts +10 -2
  31. package/src/integrations/catalog-refresh.ts +1 -1
  32. package/src/integrations/merge.ts +158 -25
  33. package/src/integrations/raycast-detect.ts +111 -0
  34. package/src/integrations/registry.ts +18 -0
  35. package/src/integrations/state.ts +82 -13
  36. package/src/integrations/writer.ts +46 -31
  37. package/src/lib/bounded-body.ts +22 -7
  38. package/src/oauth/anthropic-routing.ts +59 -14
  39. package/src/oauth/health.ts +3 -0
  40. package/src/providers/quota.ts +145 -9
  41. package/src/providers/registry.ts +31 -0
  42. package/src/responses/parser.ts +16 -3
  43. package/src/responses/reasoning-envelope.ts +3 -2
  44. package/src/server/grok-responses-control-frame.ts +43 -0
  45. package/src/server/management/config-routes.ts +6 -2
  46. package/src/server/management/integration-routes.ts +29 -2
  47. package/src/server/management/model-routes.ts +9 -1
  48. package/src/server/request-decompress.ts +34 -8
  49. package/src/server/responses/agent-task-recovery-cache.ts +43 -14
  50. package/src/server/responses/agent-task-recovery.ts +28 -16
  51. package/src/server/responses/core.ts +39 -6
  52. package/src/web-search/loop.ts +10 -2
@@ -161,6 +161,39 @@ export async function handleGrokCommand(argv: string[], deps: RuntimeApiDeps = {
161
161
  });
162
162
  }
163
163
 
164
+ /** The Raycast-only block the single-client route adds; see IntegrationStateEnvelope. */
165
+ interface RaycastStatusBlock {
166
+ plan: string;
167
+ aiDirPresent: boolean;
168
+ }
169
+
170
+ function raycastBlock(result: unknown): RaycastStatusBlock | null {
171
+ if (!result || typeof result !== "object") return null;
172
+ const block = (result as { raycast?: unknown }).raycast;
173
+ if (!block || typeof block !== "object") return null;
174
+ const { plan, aiDirPresent } = block as Partial<RaycastStatusBlock>;
175
+ return typeof plan === "string" && typeof aiDirPresent === "boolean" ? { plan, aiDirPresent } : null;
176
+ }
177
+
178
+ /**
179
+ * Text view of one client's status.
180
+ *
181
+ * Raycast carries an extra block, and the generic summary would print it as
182
+ * three dotted keys. A `current` file that Raycast ignores for want of a Pro
183
+ * subscription is the one fact this view must not bury, so `plan` gets its own
184
+ * line and a missing `ai` folder gets the instruction that creates it.
185
+ */
186
+ function singleClientStatusLines(result: unknown): string[] {
187
+ const raycast = raycastBlock(result);
188
+ if (!raycast) return summaryLines(result);
189
+ const rest = Object.fromEntries(Object.entries(result as Record<string, unknown>).filter(([key]) => key !== "raycast"));
190
+ const lines = [...summaryLines(rest), `plan: ${raycast.plan}`];
191
+ if (!raycast.aiDirPresent) {
192
+ lines.push('Open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.');
193
+ }
194
+ return lines;
195
+ }
196
+
164
197
  /**
165
198
  * The headless half of the client-integration toggle.
166
199
  *
@@ -197,7 +230,7 @@ export async function handleClientIntegrationCommand(
197
230
  : [String((result as { error?: string }).error ?? "No Aside profiles found.")]
198
231
  : rows
199
232
  ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`)
200
- : summaryLines(result));
233
+ : singleClientStatusLines(result));
201
234
  return;
202
235
  }
203
236
 
@@ -79,26 +79,37 @@ function validateAndSave(config: ReturnType<typeof loadConfig>): void {
79
79
 
80
80
  function handleList(args: string[]): void {
81
81
  const wantsJson = consumeFlag(args, "--json");
82
- rejectUnknownArgs(args, "Usage: ocx provider list [--json]");
82
+ const wantsJsonl = consumeFlag(args, "--jsonl");
83
+ rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]");
84
+
85
+ if (wantsJson && wantsJsonl) {
86
+ console.error("Use only one of --json or --jsonl.");
87
+ process.exit(1);
88
+ }
83
89
 
84
90
  const config = loadConfig();
85
91
  const configured = Object.keys(config.providers);
92
+ const entries = configured.map(name => {
93
+ const prov = config.providers[name];
94
+ const registryEntry = getProviderRegistryEntry(name);
95
+ return {
96
+ name,
97
+ adapter: prov.adapter,
98
+ baseUrl: prov.baseUrl,
99
+ authMode: prov.authMode ?? "key",
100
+ defaultModel: prov.defaultModel ?? null,
101
+ isDefault: name === config.defaultProvider,
102
+ source: registryEntry ? "registry" : "custom",
103
+ models: prov.models ?? [],
104
+ };
105
+ });
106
+
107
+ if (wantsJsonl) {
108
+ for (const entry of entries) console.log(JSON.stringify(entry));
109
+ return;
110
+ }
86
111
 
87
112
  if (wantsJson) {
88
- const entries = configured.map(name => {
89
- const prov = config.providers[name];
90
- const registryEntry = getProviderRegistryEntry(name);
91
- return {
92
- name,
93
- adapter: prov.adapter,
94
- baseUrl: prov.baseUrl,
95
- authMode: prov.authMode ?? "key",
96
- defaultModel: prov.defaultModel ?? null,
97
- isDefault: name === config.defaultProvider,
98
- source: registryEntry ? "registry" : "custom",
99
- models: prov.models ?? [],
100
- };
101
- });
102
113
  console.log(JSON.stringify({ configured: entries, registryCount: PROVIDER_REGISTRY.length }, null, 2));
103
114
  return;
104
115
  }
@@ -444,6 +455,7 @@ Subcommands:
444
455
 
445
456
  Examples:
446
457
  ocx provider list
458
+ ocx provider list --jsonl
447
459
  ocx provider add anthropic --api-key sk-ant-...
448
460
  ocx provider add my-ollama --adapter openai-chat --base-url http://localhost:11434/v1
449
461
  ocx provider show anthropic --json
@@ -287,8 +287,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
287
287
  { name: "api-key", usage: "ocx api-key <list|create|rotate|remove> ...", summary: "Alias of ocx access key." },
288
288
  {
289
289
  name: "export",
290
- usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside> [--json] [--out <path>] [--force]",
291
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.",
290
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside|raycast> [--json] [--out <path>] [--force]",
291
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.",
292
292
  details: [
293
293
  "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
294
294
  "--out <path> writes the native config there and refuses to replace an existing file without --force.",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI-versus-proxy version skew (#2701).
2
+ * CLI-versus-proxy version skew (#2701, #3464).
3
3
  *
4
4
  * The reported failure: `ocx` on PATH is an older install than the running proxy, so its
5
5
  * help describes commands the proxy does not have and its output describes a different
@@ -9,6 +9,7 @@
9
9
  * comparison instead of reimplementing it -- two diagnostics disagreeing about whether an
10
10
  * install is stale would be worse than neither reporting it.
11
11
  */
12
+ import { parseStrictSemver, type StrictSemver } from "../lib/strict-semver";
12
13
 
13
14
  /** Placeholder versions that mean "unknown", not "different". */
14
15
  const PLACEHOLDERS = new Set(["unknown", "0.0.0"]);
@@ -22,6 +23,30 @@ export interface VersionSkew {
22
23
  readonly warning: string | null;
23
24
  }
24
25
 
26
+ /** Suppressed comparisons are not confirmed matches, even when both placeholders agree. */
27
+ export function isConfirmedVersionMatch(skew: VersionSkew): boolean {
28
+ return skew.proxyVersion === skew.cliVersion && !PLACEHOLDERS.has(skew.cliVersion);
29
+ }
30
+
31
+ /** SemVer precedence ignores build metadata; raw equality is handled separately. */
32
+ function compareVersions(cli: StrictSemver, proxy: StrictSemver): number {
33
+ for (let i = 0; i < cli.core.length; i++) {
34
+ if (cli.core[i]! !== proxy.core[i]!) return cli.core[i]! > proxy.core[i]! ? 1 : -1;
35
+ }
36
+ if (cli.prerelease.length === 0) return proxy.prerelease.length === 0 ? 0 : 1;
37
+ if (proxy.prerelease.length === 0) return -1;
38
+ for (let i = 0; i < Math.max(cli.prerelease.length, proxy.prerelease.length); i++) {
39
+ const left = cli.prerelease[i];
40
+ const right = proxy.prerelease[i];
41
+ if (left === right) continue;
42
+ if (left === undefined) return -1;
43
+ if (right === undefined) return 1;
44
+ if (typeof left !== typeof right) return typeof left === "bigint" ? -1 : 1;
45
+ return left > right ? 1 : -1;
46
+ }
47
+ return 0;
48
+ }
49
+
25
50
  /**
26
51
  * Compare the running CLI against the live proxy.
27
52
  *
@@ -36,11 +61,19 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un
36
61
  if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) {
37
62
  return { cliVersion, proxyVersion: proxy, skewed: false, warning: null };
38
63
  }
64
+ const cliSemver = parseStrictSemver(cliVersion);
65
+ const proxySemver = parseStrictSemver(proxy);
66
+ const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0;
67
+ const advice = order > 0
68
+ ? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. "
69
+ + "For a background service, run ocx service repair (ocx service restart is an alias)."
70
+ : order < 0
71
+ ? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation."
72
+ : "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use.";
39
73
  return {
40
74
  cliVersion,
41
75
  proxyVersion: proxy,
42
76
  skewed: true,
43
- warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. `
44
- + "Its help and features describe a different build. Reinstall, or run the proxy's own binary.",
77
+ warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — ${advice}`,
45
78
  };
46
79
  }
@@ -1,4 +1,4 @@
1
- import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs";
1
+ import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import type { IntegrationIO } from "../integrations/config-io";
@@ -14,7 +14,7 @@ export interface AsideProfile {
14
14
  }
15
15
 
16
16
  const MAX_PROFILES = 128;
17
- const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
17
+ const MAX_MANIFEST_BYTES = 4n * 1024n * 1024n;
18
18
  const MAX_LEAF_LINKS = 40;
19
19
 
20
20
  function refuse(message: string): never {
@@ -30,9 +30,10 @@ function object(value: unknown): value is Record<string, unknown> {
30
30
  return value !== null && typeof value === "object" && !Array.isArray(value);
31
31
  }
32
32
 
33
- function inspect(path: string, follow = false): Stats | null {
33
+ function inspect(path: string, follow = false): BigIntStats | null {
34
34
  try {
35
- return follow ? statSync(path) : lstatSync(path);
35
+ // File IDs can exceed Number's exact integer range; never round identities.
36
+ return follow ? statSync(path, { bigint: true }) : lstatSync(path, { bigint: true });
36
37
  } catch (error) {
37
38
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
38
39
  return refuse("a filesystem boundary could not be inspected.");
@@ -110,10 +111,10 @@ export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: st
110
111
  return readProfiles(root);
111
112
  }
112
113
 
113
- type DirectoryIdentity = { path: string; dev: number; ino: number };
114
+ type DirectoryIdentity = { path: string; dev: bigint; ino: bigint };
114
115
  type Boundary = Array<DirectoryIdentity | null>;
115
116
 
116
- function sameIdentity(a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">): boolean {
117
+ function sameIdentity(a: Pick<BigIntStats, "dev" | "ino">, b: Pick<BigIntStats, "dev" | "ino">): boolean {
117
118
  return a.dev === b.dev && a.ino === b.ino;
118
119
  }
119
120
 
@@ -162,7 +163,7 @@ function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boo
162
163
  }
163
164
  if (absent) return identities;
164
165
  const leaf = inspect(profile.configPath);
165
- if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) {
166
+ if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1n)) {
166
167
  refuse("the model catalog is a link, shared file or non-regular file.");
167
168
  }
168
169
  if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) {
@@ -93,7 +93,8 @@ export type ExportClientId =
93
93
  | "mcode"
94
94
  | "zcode"
95
95
  | "prime"
96
- | "aside";
96
+ | "aside"
97
+ | "raycast";
97
98
 
98
99
  export interface ExportClientSpec {
99
100
  id: ExportClientId;
@@ -0,0 +1,106 @@
1
+ import { exportPresentationLabel } from "../model-presentation";
2
+ import { OPENCODE_PROVIDER_ID } from "./constants";
3
+ import type { ExportContext, ManagedContribution } from "./contracts";
4
+ import { authoritativeContextWindow, normalizeExportModels, singleFragment } from "./model-metadata";
5
+
6
+ export interface RaycastAbility {
7
+ supported: boolean;
8
+ }
9
+
10
+ export type RaycastAbilityName =
11
+ | "temperature"
12
+ | "vision"
13
+ | "system_message"
14
+ | "tools"
15
+ | "reasoning_effort";
16
+
17
+ export interface RaycastModelEntry {
18
+ id: string;
19
+ name: string;
20
+ context?: number;
21
+ abilities: Record<RaycastAbilityName, RaycastAbility>;
22
+ }
23
+
24
+ export interface RaycastProviderEntry {
25
+ id: string;
26
+ name: string;
27
+ base_url: string;
28
+ models: RaycastModelEntry[];
29
+ }
30
+
31
+ export interface RaycastGeneratedConfig {
32
+ providers: RaycastProviderEntry[];
33
+ }
34
+
35
+ /**
36
+ * Raycast appends `/chat/completions` to `base_url`, so the proxy's `/v1`
37
+ * root is passed through unchanged. The format has no safe credential
38
+ * interpolation, which is why the registry exposes it only on loopback.
39
+ */
40
+ export function buildRaycastClientConfig(ctx: ExportContext): RaycastGeneratedConfig {
41
+ const models: RaycastModelEntry[] = normalizeExportModels(ctx.models).map(model => {
42
+ const hasLadder = (model.reasoningEfforts?.length ?? 0) > 0;
43
+ const context = authoritativeContextWindow(model.contextWindow);
44
+ return {
45
+ id: model.namespaced,
46
+ name: exportPresentationLabel(model),
47
+ ...(context !== undefined ? { context } : {}),
48
+ abilities: {
49
+ temperature: { supported: !hasLadder },
50
+ vision: { supported: model.inputModalities?.includes("image") ?? false },
51
+ system_message: { supported: true },
52
+ // Existing client-export convention, not a verified per-model capability:
53
+ // ExportModel has no authoritative tool-support field.
54
+ tools: { supported: true },
55
+ reasoning_effort: { supported: hasLadder },
56
+ },
57
+ };
58
+ });
59
+ return {
60
+ providers: [
61
+ { id: OPENCODE_PROVIDER_ID, name: "OpenCodex", base_url: ctx.baseUrl, models },
62
+ ],
63
+ };
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ export function summarizeRaycast(
71
+ document: unknown,
72
+ ): { modelCount: number; modelsWithoutLimits: number } {
73
+ const empty = { modelCount: 0, modelsWithoutLimits: 0 };
74
+ if (!isRecord(document) || !Array.isArray(document.providers)) return empty;
75
+ const providers = document.providers.filter(
76
+ provider => isRecord(provider) && provider.id === OPENCODE_PROVIDER_ID,
77
+ );
78
+ // An ambiguous managed provider has no meaningful summary either.
79
+ if (providers.length !== 1) return empty;
80
+ const provider: unknown = providers[0];
81
+ if (!isRecord(provider) || !Array.isArray(provider.models)) return empty;
82
+ const models = provider.models.filter((model): model is Record<string, unknown> => (
83
+ isRecord(model)
84
+ && typeof model.id === "string" && model.id.trim().length > 0
85
+ && typeof model.name === "string" && model.name.trim().length > 0
86
+ ));
87
+ return {
88
+ modelCount: models.length,
89
+ modelsWithoutLimits: models.filter(model => (
90
+ typeof model.context !== "number" || authoritativeContextWindow(model.context) === undefined
91
+ )).length,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Raycast stores providers in a sequence. The stable id selector owns only
97
+ * OpenCodex's element, preserving user-defined providers around it.
98
+ */
99
+ export function buildRaycastContribution(ctx: ExportContext): ManagedContribution {
100
+ const doc = buildRaycastClientConfig(ctx);
101
+ return singleFragment(
102
+ "raycast",
103
+ ["providers", `[id=${OPENCODE_PROVIDER_ID}]`],
104
+ doc.providers[0]!,
105
+ );
106
+ }
@@ -37,6 +37,8 @@ export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./conf
37
37
  export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode";
38
38
  export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh";
39
39
  export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode";
40
+ export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast";
41
+ export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
40
42
 
41
43
  import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
42
44
  import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
@@ -45,6 +47,7 @@ import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./conf
45
47
  import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
46
48
  import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode";
47
49
  import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode";
50
+ import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
48
51
 
49
52
 
50
53
 
@@ -533,6 +536,22 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
533
536
  return join(asideAccountDir(env, home), "models.json");
534
537
  }
535
538
 
539
+ /**
540
+ * Raycast's Custom Providers directory. Raycast hard-codes
541
+ * `~/.config/raycast/ai` on macOS AND Windows: it neither honors
542
+ * `XDG_CONFIG_HOME` nor ships a variable of its own that relocates the file, so
543
+ * unlike `opencodeGlobalConfigPath` there is no override to mirror and the env
544
+ * parameter exists only to keep the resolver signature uniform with the rest.
545
+ */
546
+ export function raycastAiDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
547
+ return join(home, ".config", "raycast", "ai");
548
+ }
549
+
550
+ /** The providers file Raycast watches (manual.raycast.com/ai/custom-providers). */
551
+ export function raycastConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
552
+ return join(raycastAiDir(env, home), "providers.yaml");
553
+ }
554
+
536
555
  /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */
537
556
  function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection {
538
557
  const options: OpencodeProviderConnection = { baseURL };
@@ -1259,6 +1278,23 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1259
1278
  // bind would generate a config that 401s.
1260
1279
  loopbackOnly: true,
1261
1280
  },
1281
+ raycast: {
1282
+ id: "raycast",
1283
+ // Not a bare `providers.yaml`: same Downloads-folder collision argument as
1284
+ // `aside-models.json`.
1285
+ filename: "raycast-providers.yaml",
1286
+ destination: env => raycastConfigPath(env),
1287
+ apiKeyEnv: "",
1288
+ exportHint: "Raycast reads providers.yaml with no api_keys entry; loopback needs no key.",
1289
+ build: buildRaycastClientConfig,
1290
+ format: "yaml",
1291
+ summarize: summarizeRaycast,
1292
+ buildContribution: buildRaycastContribution,
1293
+ // Raycast's provider entry has no header field, and its `api_keys` value
1294
+ // is read literally (no env interpolation), so the only way to admit a
1295
+ // remote bind would be a plaintext secret on disk. Refuse instead.
1296
+ loopbackOnly: true,
1297
+ },
1262
1298
  };
1263
1299
 
1264
1300
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -0,0 +1,61 @@
1
+ import { CURSOR_CAPABILITIES } from "../adapters/cursor/catalog";
2
+ import { nativeOpenAiCapabilityDisplayName } from "../codex/catalog/metadata";
3
+ import type { ExportModel } from "./config-export/contracts";
4
+
5
+ const KNOWN_ACRONYMS = new Set(["gpt", "glm", "grok"]);
6
+
7
+ function titleWord(word: string): string {
8
+ const lower = word.toLowerCase();
9
+ if (KNOWN_ACRONYMS.has(lower)) return lower.toUpperCase();
10
+ if (/^\d+\.\d+$/.test(word)) return word;
11
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
12
+ }
13
+
14
+ /**
15
+ * Last-resort label when no catalog or operator name exists. Joins dotted version
16
+ * tails (`5-1` → `5.1`, `2-5` → `2.5`) so Raycast reads like a product name
17
+ * instead of a slug.
18
+ */
19
+ function humanizeModelSlug(modelId: string): string {
20
+ const parts = modelId.split("-");
21
+ const words: string[] = [];
22
+ for (let index = 0; index < parts.length; index += 1) {
23
+ const part = parts[index]!;
24
+ const next = parts[index + 1];
25
+ if (/^\d+$/.test(part) && next !== undefined && /^\d+$/.test(next)) {
26
+ words.push(`${part}.${next}`);
27
+ index += 1;
28
+ continue;
29
+ }
30
+ words.push(part);
31
+ }
32
+ return words.map(titleWord).join(" ");
33
+ }
34
+
35
+ function wireModelId(model: ExportModel): string {
36
+ if (model.id?.trim()) return model.id.trim();
37
+ const slash = model.namespaced.lastIndexOf("/");
38
+ return slash >= 0 ? model.namespaced.slice(slash + 1) : model.namespaced;
39
+ }
40
+
41
+ /**
42
+ * Human-facing model label for clients whose picker shows `name` verbatim.
43
+ *
44
+ * Raycast has no second column for provider, so the shared `exportModelLabel`
45
+ * suffix `(anthropic)` would be noise — and its fallback is the raw wire id
46
+ * because management slugs are deliberately withheld from ExportModel. Resolve
47
+ * operator labels first, then the canonical capability tables, then a slug
48
+ * humanizer.
49
+ */
50
+ export function exportPresentationLabel(model: ExportModel): string {
51
+ const configured = model.displayName?.trim();
52
+ if (configured) return configured;
53
+ const wireId = wireModelId(model);
54
+ const fromCursor = CURSOR_CAPABILITIES[wireId]?.displayName;
55
+ if (fromCursor) return fromCursor;
56
+ if (model.native) {
57
+ const native = nativeOpenAiCapabilityDisplayName(wireId);
58
+ if (native) return native;
59
+ }
60
+ return humanizeModelSlug(wireId);
61
+ }
@@ -307,6 +307,11 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<Ocx
307
307
  return slug;
308
308
  }
309
309
 
310
+ /**
311
+ * Cria uma entrada nativa ou roteada a partir do snapshot upstream, de um clone
312
+ * do template ou de campos mínimos. Aplica os metadados e limites pertinentes
313
+ * sem alterar o template nem herdar sua marca de nome ou histórico de prioridade.
314
+ */
310
315
  export function deriveEntry(
311
316
  template: RawEntry | null,
312
317
  slug: string,
@@ -332,6 +337,7 @@ export function deriveEntry(
332
337
  }
333
338
  if (template || codexForwardNativeCapabilityAlias) {
334
339
  const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry;
340
+ delete e.opencodex_native_display_name;
335
341
  // A cached template may carry display-order history; each new row owns its natural rank.
336
342
  delete e[SPAWN_PRIORITY_FIELD];
337
343
  e.slug = slug;
@@ -773,6 +779,20 @@ function recoverableNativeSlug(entry: RawEntry): string | null {
773
779
  : null;
774
780
  }
775
781
 
782
+ /** Undo our display overlay before native metadata normalization and template reuse. */
783
+ function restoreNativeDisplayName(entry: RawEntry): RawEntry {
784
+ const saved = entry.opencodex_native_display_name;
785
+ delete entry.opencodex_native_display_name;
786
+ if (saved && typeof saved === "object" && !Array.isArray(saved)) {
787
+ const label = saved as Record<string, unknown>;
788
+ if (recoverableNativeSlug(entry) === label.slug
789
+ && typeof label.original === "string" && entry.display_name === label.applied) {
790
+ entry.display_name = label.original;
791
+ }
792
+ }
793
+ return entry;
794
+ }
795
+
776
796
  /** Append missing supported native rows from trusted catalog sources only. */
777
797
  export function mergeCatalogModelsWithNativeRecovery(
778
798
  primaryCatalogModels: readonly RawEntry[],
@@ -862,6 +882,8 @@ export interface ObservedCatalogMergeInput {
862
882
  readonly suppressedBareNativeSlugs?: ReadonlySet<string>;
863
883
  readonly policy: ObservedCatalogMergePolicy;
864
884
  readonly openaiContextCap?: NativeContextLimitsInput;
885
+ /** Exact display-only labels for bare native OpenAI models. */
886
+ readonly nativeDisplayNames?: Readonly<Record<string, string>>;
865
887
  }
866
888
 
867
889
  /**
@@ -896,12 +918,14 @@ export function mergeCatalogEntriesFromObservedState({
896
918
  suppressedBareNativeSlugs = new Set(),
897
919
  policy,
898
920
  openaiContextCap,
921
+ nativeDisplayNames,
899
922
  }: ObservedCatalogMergeInput): RawEntry[] {
900
923
  // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at
901
924
  // the observed-core boundary so callers can safely retain evidence objects or repeat the merge.
902
- const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry);
925
+ const detachedCatalogModels = catalogModels
926
+ .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry));
903
927
  const detachedBaselineCatalogModels = baselineCatalogModels
904
- .map(entry => structuredClone(entry) as RawEntry);
928
+ .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry));
905
929
  const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry);
906
930
  // Track this invocation's generated custom rows, not ownership markers read from disk.
907
931
  // Their builder already finalized exact native ladders and ordinary routed mock tiers.
@@ -1256,6 +1280,17 @@ export function mergeCatalogEntriesFromObservedState({
1256
1280
  );
1257
1281
  applyFullModelPickerOrder(versionedEntries, modelPickerOrder);
1258
1282
  for (const entry of versionedEntries) {
1283
+ // Templates and account clones must not inherit the native row's overlay marker.
1284
+ delete entry.opencodex_native_display_name;
1285
+ const slug = recoverableNativeSlug(entry);
1286
+ if (slug !== null) {
1287
+ const label = nativeDisplayNames && Object.hasOwn(nativeDisplayNames, slug)
1288
+ ? nativeDisplayNames[slug]?.trim() : undefined;
1289
+ if (label && label !== entry.display_name) {
1290
+ entry.opencodex_native_display_name = { slug, original: entry.display_name, applied: label };
1291
+ entry.display_name = label;
1292
+ }
1293
+ }
1259
1294
  const kind = entry.opencodex_catalog_kind;
1260
1295
  if (trustedAccountBoundNativeCatalogSlug(entry) === undefined
1261
1296
  && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND
@@ -1659,6 +1694,12 @@ export function finalizeAutoReviewModelOverride(
1659
1694
  return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
1660
1695
  }
1661
1696
 
1697
+ /**
1698
+ * Mescla o catálogo retido com os modelos visíveis e as configurações atuais,
1699
+ * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão
1700
+ * de escrita para publicar o resultado apenas se os bytes mudarem, retornando
1701
+ * a contagem de entradas roteadas e por conta, o caminho e o estado da gravação.
1702
+ */
1662
1703
  function writeRetainedCatalogSync({
1663
1704
  config,
1664
1705
  goModels,
@@ -1880,6 +1921,7 @@ function writeRetainedCatalogSync({
1880
1921
  accountBoundEntries,
1881
1922
  suppressedBareNativeSlugs,
1882
1923
  openaiContextCap,
1924
+ nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
1883
1925
  policy: {
1884
1926
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
1885
1927
  nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
@@ -226,6 +226,12 @@ function bindGatherPaths(
226
226
  };
227
227
  }
228
228
 
229
+ /**
230
+ * Prepara um candidato de catálogo para convergência sem gravá-lo em disco.
231
+ * Clona a fonte e mescla as observações nativas, os modelos roteados e por conta,
232
+ * aplicando a configuração, inclusive nomes nativos, e os limites de raciocínio
233
+ * observados no runtime antes de retornar o catálogo resultante.
234
+ */
229
235
  function prepareCatalog(
230
236
  config: Readonly<OcxConfig>,
231
237
  source: Extract<CatalogSourceForGather, { kind: "available" }>,
@@ -366,6 +372,7 @@ function prepareCatalog(
366
372
  accountBoundEntries,
367
373
  suppressedBareNativeSlugs,
368
374
  openaiContextCap,
375
+ nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
369
376
  policy: {
370
377
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
371
378
  nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],