cartethyia 2.7.6 → 2.7.7

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 (126) hide show
  1. package/.env.example +4 -2
  2. package/README.md +25 -1
  3. package/dist/dashboard/assets/{BannedPage-B1N603cM.js → BannedPage-B05aF8Dg.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-B05aF8Dg.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-9q4_4NoK.js → CliToolDetailPage-CLUhmjUy.js} +2 -2
  6. package/dist/dashboard/assets/CliToolDetailPage-CLUhmjUy.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DwshGtK-.js → CliToolsPage-CQmrqqrk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-CQmrqqrk.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-D762MIkp.js → CombosPage-CHG_1Wr3.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CHG_1Wr3.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-KRYjHZsX.js → ConsoleLogPage-DPgbklad.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-DPgbklad.js.gz +0 -0
  13. package/dist/dashboard/assets/CustomizationPage-pnfAqr_-.js +1 -0
  14. package/dist/dashboard/assets/CustomizationPage-pnfAqr_-.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-DGJuU2A6.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-DGJuU2A6.js.gz +0 -0
  17. package/dist/dashboard/assets/{InjectSystemPromptPanel-d5v3K_Yc.js → InjectSystemPromptPanel-C-e2AMz6.js} +2 -2
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-C-e2AMz6.js.gz +0 -0
  19. package/dist/dashboard/assets/{LoginPage-Ccv4tjNS.js → LoginPage-J8wPbmfT.js} +1 -1
  20. package/dist/dashboard/assets/LoginPage-J8wPbmfT.js.gz +0 -0
  21. package/dist/dashboard/assets/{OverviewPage-DN9fYNWA.js → OverviewPage-BSQnF2Lp.js} +1 -1
  22. package/dist/dashboard/assets/OverviewPage-BSQnF2Lp.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DK_e0FJz.js +7 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-DK_e0FJz.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-D_a3r6Gc.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-D_a3r6Gc.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-DxjvUzSD.js → ProxyPage-BBTRBAQ3.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BBTRBAQ3.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B2WqMSG1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B2WqMSG1.js.gz +0 -0
  31. package/dist/dashboard/assets/{SettingsPage-BW4AKj2H.js → SettingsPage-D7xp5Ij_.js} +1 -1
  32. package/dist/dashboard/assets/SettingsPage-D7xp5Ij_.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-44BZrxFC.js → SetupPage-LND6_Ss7.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-LND6_Ss7.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-DRPavCdU.js → StudioPage-BzNnm6Bi.js} +2 -2
  36. package/dist/dashboard/assets/StudioPage-BzNnm6Bi.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-B7M7LvMM.js → UsagePage-C1TbL24N.js} +2 -2
  38. package/dist/dashboard/assets/UsagePage-C1TbL24N.js.gz +0 -0
  39. package/dist/dashboard/assets/{entry-D6WvojeD.js → entry-vqGb8yVz.js} +3 -3
  40. package/dist/dashboard/assets/entry-vqGb8yVz.js.gz +0 -0
  41. package/dist/dashboard/assets/formatters-DP-5E01-.js +1 -0
  42. package/dist/dashboard/assets/formatters-DP-5E01-.js.gz +0 -0
  43. package/dist/dashboard/assets/{index-Ko4dLRnV.js → index-D36vCZht.js} +2 -2
  44. package/dist/dashboard/assets/index-D36vCZht.js.gz +0 -0
  45. package/dist/dashboard/assets/{select-BHqpd-bg.js → select-C9Ssle9b.js} +1 -1
  46. package/dist/dashboard/assets/select-C9Ssle9b.js.gz +0 -0
  47. package/dist/dashboard/index.html +2 -2
  48. package/dist/dashboard/index.html.gz +0 -0
  49. package/dist/dashboard/precompressed-assets.json +22 -22
  50. package/migrations/full-postgres/0043_add_provider_custom_user_agent.sql +14 -0
  51. package/migrations/lite-sqlite3/0043_add_provider_custom_user_agent.sql +10 -0
  52. package/package.json +3 -1
  53. package/src/config/security.ts +1 -1
  54. package/src/config/spec.ts +13 -6
  55. package/src/console/cli-tools/cartethyia-websearch-plugin.ts +186 -0
  56. package/src/console/cli-tools/injectors/opencode.ts +42 -14
  57. package/src/console/providers/catalog/contracts.ts +4 -0
  58. package/src/console/providers/catalog/routes.ts +1 -0
  59. package/src/console/providers/detail/routes.ts +8 -0
  60. package/src/console/providers/detail/store.ts +4 -0
  61. package/src/console/quota/account-quota-tenant.ts +74 -0
  62. package/src/observability/telemetry-buffer.ts +2 -1
  63. package/src/persistence/boot.ts +13 -4
  64. package/src/persistence/schema.ts +7 -1
  65. package/src/protocol/canonical-model.ts +7 -0
  66. package/src/protocol/request/gemini.ts +20 -1
  67. package/src/protocol/tool-identity.ts +15 -1
  68. package/src/providers/default-registry.ts +8 -2
  69. package/src/providers/discovery/discovery-types.ts +18 -0
  70. package/src/providers/discovery/probe-phases.ts +45 -0
  71. package/src/providers/discovery/probing-service.ts +41 -6
  72. package/src/providers/integrations/antigravity/antigravity-protocol.ts +20 -2
  73. package/src/providers/integrations/antigravity/antigravity.ts +24 -0
  74. package/src/providers/integrations/codex/codex.ts +28 -1
  75. package/src/providers/integrations/qoder/chat-proto.ts +328 -0
  76. package/src/providers/integrations/qoder/claim-qoder-credits.ts +258 -0
  77. package/src/providers/integrations/qoder/qoder-adapter.ts +275 -0
  78. package/src/providers/integrations/qoder/qoder-auth.ts +201 -0
  79. package/src/providers/integrations/qoder/qoder-body.ts +78 -0
  80. package/src/providers/integrations/qoder/qoder-catalog.ts +5 -1
  81. package/src/providers/integrations/qoder/qoder-chat.ts +514 -0
  82. package/src/providers/integrations/qoder/qoder-cosy.ts +162 -0
  83. package/src/providers/integrations/qoder/qoder-grpc.ts +667 -0
  84. package/src/providers/integrations/qoder/qoder-ide.ts +584 -0
  85. package/src/providers/integrations/qoder/qoder-oauth.ts +4 -4
  86. package/src/providers/integrations/qoder/qoder-quota.ts +1 -1
  87. package/src/providers/integrations/qoder/qoder-realm.ts +40 -4
  88. package/src/providers/integrations/qoder/qoder-types.ts +51 -0
  89. package/src/providers/integrations/qoder/qoder-umid.ts +123 -8
  90. package/src/providers/integrations/qoder/qoder.ts +147 -1889
  91. package/src/providers/provider-contracts.ts +7 -0
  92. package/src/providers/provider-usage.ts +9 -6
  93. package/src/security/abuse.ts +5 -5
  94. package/src/transport/dispatch/upstream.ts +4 -2
  95. package/src/transport/routing/route-catalog.ts +8 -0
  96. package/src/transport/routing/route-model.ts +4 -0
  97. package/src/transport/routing/router.ts +8 -117
  98. package/dist/dashboard/assets/BannedPage-B1N603cM.js.gz +0 -0
  99. package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js.gz +0 -0
  100. package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js.gz +0 -0
  101. package/dist/dashboard/assets/CombosPage-D762MIkp.js.gz +0 -0
  102. package/dist/dashboard/assets/ConsoleLogPage-KRYjHZsX.js.gz +0 -0
  103. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js +0 -1
  104. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js.gz +0 -0
  105. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js +0 -1
  106. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js.gz +0 -0
  107. package/dist/dashboard/assets/InjectSystemPromptPanel-d5v3K_Yc.js.gz +0 -0
  108. package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js.gz +0 -0
  109. package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js.gz +0 -0
  110. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js +0 -5
  111. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js.gz +0 -0
  112. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js +0 -1
  113. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js.gz +0 -0
  114. package/dist/dashboard/assets/ProxyPage-DxjvUzSD.js.gz +0 -0
  115. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js +0 -1
  116. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js.gz +0 -0
  117. package/dist/dashboard/assets/SettingsPage-BW4AKj2H.js.gz +0 -0
  118. package/dist/dashboard/assets/SetupPage-44BZrxFC.js.gz +0 -0
  119. package/dist/dashboard/assets/StudioPage-DRPavCdU.js.gz +0 -0
  120. package/dist/dashboard/assets/UsagePage-B7M7LvMM.js.gz +0 -0
  121. package/dist/dashboard/assets/entry-D6WvojeD.js.gz +0 -0
  122. package/dist/dashboard/assets/formatters-u7JPB5BX.js +0 -1
  123. package/dist/dashboard/assets/formatters-u7JPB5BX.js.gz +0 -0
  124. package/dist/dashboard/assets/index-Ko4dLRnV.js.gz +0 -0
  125. package/dist/dashboard/assets/select-BHqpd-bg.js.gz +0 -0
  126. package/src/providers/integrations/qoder/qoder-text-tools.ts +0 -277
@@ -0,0 +1,186 @@
1
+ /**
2
+ * OpenCode websearch provider plugin generation.
3
+ *
4
+ * The Console writes `websearch.provider = "<provider>"` into the user's
5
+ * opencode.jsonc. That value resolves to a WebSearch provider the local plugin
6
+ * below registers with OpenCode. The plugin reads the gateway URL and API key
7
+ * from the matching provider entry in opencode.jsonc — the same provider block
8
+ * the injector writes — so the search route follows the configured provider.
9
+ *
10
+ * OpenCode loads every `.js` file under `~/.config/opencode/plugins/`
11
+ * (or `<config-dir>/plugins/`) as a plugin; no registration step is needed
12
+ * beyond writing the file.
13
+ */
14
+ import { ensureDir, fileExists, join, removeFile, writeTextFileWithBackup } from "./fs-ops";
15
+
16
+ const PLUGIN_FILENAME = (providerName: string) => `${providerName}-websearch.js`;
17
+
18
+ /** Escapes the provider name for use inside a JS double-quoted string. */
19
+ function jsStringLiteral(value: string): string {
20
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
21
+ }
22
+
23
+ /**
24
+ * Renders the websearch plugin source for one provider name.
25
+ *
26
+ * `providerName` is the exact provider key written into opencode.jsonc (e.g.
27
+ * `Cartethyia`, `CarteLocal`). The plugin reads that block's `baseURL` and
28
+ * `apiKey` as the single source of truth, so no secret is duplicated anywhere.
29
+ */
30
+ export function renderWebsearchPlugin(providerName: string): string {
31
+ const quoted = jsStringLiteral(providerName);
32
+ return `// ${quoted}-websearch — route OpenCode's built-in websearch tool through the
33
+ // Cartethyia gateway's /v1/search native route, using the "${quoted}"
34
+ // provider block in opencode.jsonc as the single source of truth.
35
+ //
36
+ // Generated by the Cartethyia console (CLI tools → OpenCode). Do not edit by
37
+ // hand; re-apply the tool config to regenerate.
38
+ //
39
+ // Env overrides (all optional):
40
+ // CARTETHYIA_SEARCH_URL (default: the provider's baseURL + /search)
41
+ // CARTETHYIA_SEARCH_API_KEY (default: the provider's apiKey)
42
+ // CARTETHYIA_SEARCH_BACKEND (default: "exa"; "parallel" also works)
43
+
44
+ import { Plugin } from "@opencode/plugin";
45
+ import { readFileSync } from "node:fs";
46
+ import { homedir } from "node:os";
47
+ import { join } from "node:path";
48
+
49
+ function pick(obj, keys) {
50
+ for (const k of keys) {
51
+ const v = obj?.[k];
52
+ if (v !== undefined && v !== null) return v;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ // Pull the "${quoted}" provider's baseURL/apiKey out of opencode.jsonc.
58
+ // The file is JSONC (comments allowed) so search for the provider block
59
+ // loosely rather than parsing strictly.
60
+ function readProviderSettings() {
61
+ const candidates = [
62
+ process.env.OPENCODE_CONFIG,
63
+ join(homedir(), ".config", "opencode", "opencode.jsonc"),
64
+ join(homedir(), ".config", "opencode", "opencode.json"),
65
+ ];
66
+ for (const path of candidates) {
67
+ if (!path) continue;
68
+ let text;
69
+ try {
70
+ text = readFileSync(path, "utf8");
71
+ } catch {
72
+ continue;
73
+ }
74
+ // Find the provider block, then the next provider block or closing brace.
75
+ const providerStart = text.indexOf(\`"\${PROVIDER_NAME}"\`);
76
+ if (providerStart < 0) continue;
77
+ const settingsMatch = text.slice(providerStart).match(/"settings"\\s*:\\s*\\{/);
78
+ if (!settingsMatch) continue;
79
+ const settingsStart = providerStart + settingsMatch.index + settingsMatch[0].length - 1;
80
+ let depth = 0;
81
+ let end = -1;
82
+ for (let i = settingsStart; i < text.length; i++) {
83
+ const ch = text[i];
84
+ if (ch === "{") depth += 1;
85
+ else if (ch === "}") {
86
+ depth -= 1;
87
+ if (depth === 0) {
88
+ end = i;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ if (end < 0) continue;
94
+ const settings = text.slice(settingsStart, end + 1);
95
+ const baseURL = settings.match(/"baseURL"\\s*:\\s*"([^"]+)"/)?.[1];
96
+ const apiKey = settings.match(/"apiKey"\\s*:\\s*"([^"]+)"/)?.[1];
97
+ if (baseURL) {
98
+ return { baseURL, apiKey };
99
+ }
100
+ }
101
+ return { baseURL: undefined, apiKey: undefined };
102
+ }
103
+
104
+ const PROVIDER_NAME = "${quoted}";
105
+ const local = readProviderSettings();
106
+ const BASE_URL = (process.env.CARTETHYIA_SEARCH_URL || (local.baseURL ? \`\${local.baseURL.replace(/\\/+$/, "")}/search\` : "")).replace(/\\/+$/, "");
107
+ const API_KEY = process.env.CARTETHYIA_SEARCH_API_KEY || local.apiKey || "";
108
+ const BACKEND = process.env.CARTETHYIA_SEARCH_BACKEND || "exa";
109
+
110
+ export default Plugin.define({
111
+ id: "${quoted}-websearch",
112
+ async setup(ctx) {
113
+ await ctx.websearch.transform((editor) => {
114
+ editor.add({
115
+ id: "${quoted}",
116
+ name: "Cartethyia Search",
117
+ execute: async ({ query }, { signal }) => {
118
+ if (!BASE_URL) {
119
+ throw new Error(
120
+ "Cartethyia search gateway is not configured: set CARTETHYIA_SEARCH_URL or add a ${quoted} provider with baseURL in opencode.jsonc",
121
+ );
122
+ }
123
+ const response = await fetch(BASE_URL, {
124
+ method: "POST",
125
+ headers: {
126
+ "content-type": "application/json",
127
+ ...(API_KEY ? { authorization: \`Bearer \${API_KEY}\` } : {}),
128
+ },
129
+ body: JSON.stringify({
130
+ model: BACKEND,
131
+ query,
132
+ max_results: 8,
133
+ }),
134
+ signal,
135
+ });
136
+ if (!response.ok) {
137
+ const text = await response.text().catch(() => "");
138
+ throw new Error(
139
+ \`Cartethyia search failed (HTTP \${response.status}): \${text.slice(0, 200)}\`,
140
+ );
141
+ }
142
+ const data = await response.json();
143
+ const results = Array.isArray(data?.results) ? data.results : [];
144
+ return results.map((item) => ({
145
+ url: String(item?.url ?? ""),
146
+ title: typeof item?.title === "string" && item.title ? item.title : undefined,
147
+ content: typeof item?.snippet === "string" && item.snippet ? item.snippet : undefined,
148
+ time:
149
+ typeof item?.published_at === "string" && item.published_at
150
+ ? { published: Date.parse(item.published_at) || undefined }
151
+ : {},
152
+ }));
153
+ },
154
+ });
155
+ editor.default.set("${quoted}");
156
+ });
157
+ },
158
+ });
159
+ `;
160
+ }
161
+
162
+ /** Writes the websearch plugin for a provider into the OpenCode config dir. */
163
+ export async function writeWebsearchPlugin(
164
+ configDir: string,
165
+ providerName: string,
166
+ ): Promise<string> {
167
+ const pluginsDir = join(configDir, "plugins");
168
+ await ensureDir(pluginsDir);
169
+ const filename = PLUGIN_FILENAME(providerName);
170
+ const target = join(pluginsDir, filename);
171
+ await writeTextFileWithBackup(target, renderWebsearchPlugin(providerName));
172
+ return target;
173
+ }
174
+
175
+ /** Removes the websearch plugin for a provider from the OpenCode config dir. */
176
+ export async function removeWebsearchPlugin(
177
+ configDir: string,
178
+ providerName: string,
179
+ ): Promise<boolean> {
180
+ const target = join(configDir, "plugins", PLUGIN_FILENAME(providerName));
181
+ if (await fileExists(target)) {
182
+ await removeFile(target);
183
+ return true;
184
+ }
185
+ return false;
186
+ }
@@ -2,6 +2,7 @@ import type {} from "../contracts";
2
2
  import { ensureV1Suffix, homeDir, join, readJsonFile, writeJsonFileWithBackup } from "../fs-ops";
3
3
  import type { InjectorSpec } from "../contracts";
4
4
  import { DEFAULT_SDK } from "../contracts";
5
+ import { writeWebsearchPlugin, removeWebsearchPlugin } from "../cartethyia-websearch-plugin";
5
6
 
6
7
 
7
8
  // OpenCode injector spec.
@@ -19,6 +20,7 @@ interface OpencodeProvider {
19
20
  interface OpencodeConfig {
20
21
  provider?: Record<string, OpencodeProvider>;
21
22
  model?: string;
23
+ websearch?: { provider?: string };
22
24
  agent?: {
23
25
  explorer?: {
24
26
  description?: string;
@@ -51,11 +53,14 @@ export const opencodeSpec: InjectorSpec = {
51
53
  if (!config) {
52
54
  return { configured: false, currentEndpoint: null, rawApiKey: null, currentModels: null };
53
55
  }
54
- const provider = config.provider?.cartethyia;
56
+ // The provider key written by this injector is the websearch provider name
57
+ // when one is configured, otherwise the default `cartethyia`.
58
+ const providerId = config.websearch?.provider ?? "cartethyia";
59
+ const provider = config.provider?.[providerId];
55
60
  const endpoint = provider?.options?.baseURL ?? null;
56
61
  const models = provider?.models ? Object.keys(provider.models) : null;
57
62
  const active =
58
- typeof config.model === "string" && config.model.startsWith("cartethyia/")
63
+ typeof config.model === "string" && config.model.startsWith(`${providerId}/`)
59
64
  ? config.model.split("/").slice(1).join("/")
60
65
  : null;
61
66
  return {
@@ -70,10 +75,15 @@ export const opencodeSpec: InjectorSpec = {
70
75
  const existing = (await readJsonFile(path)) as OpencodeConfig | null;
71
76
  const config: OpencodeConfig = (existing ?? {}) as OpencodeConfig;
72
77
 
78
+ // The provider key written into opencode.jsonc follows the websearch
79
+ // provider name when one is chosen, which keeps the websearch plugin and
80
+ // the provider block pointing at the same entry.
81
+ const providerId = input.websearch ?? "cartethyia";
82
+
73
83
  if (!config.provider) config.provider = {};
74
- const prior = config.provider.cartethyia;
75
- const otherProviders = Object.keys(config.provider).filter((id) => id !== "cartethyia");
76
- config.provider.cartethyia = {
84
+ const prior = config.provider[providerId];
85
+ const otherProviders = Object.keys(config.provider).filter((id) => id !== providerId);
86
+ config.provider[providerId] = {
77
87
  npm: input.sdk ?? prior?.npm ?? DEFAULT_SDK,
78
88
  options: {
79
89
  ...(prior?.options ?? {}),
@@ -84,7 +94,7 @@ export const opencodeSpec: InjectorSpec = {
84
94
  };
85
95
 
86
96
  const activeModel = input.activeModel ?? input.modelIds[0] ?? "";
87
- config.model = activeModel === "" ? "" : `cartethyia/${activeModel}`;
97
+ config.model = activeModel === "" ? "" : `${providerId}/${activeModel}`;
88
98
 
89
99
  if (input.websearch !== undefined) {
90
100
  config.websearch = { provider: input.websearch };
@@ -107,13 +117,20 @@ export const opencodeSpec: InjectorSpec = {
107
117
  config.agent.explorer = {
108
118
  description: "Fast explorer subagent",
109
119
  mode: "subagent",
110
- model: `cartethyia/${subagentModel}`,
120
+ model: `${providerId}/${subagentModel}`,
111
121
  };
112
122
 
113
123
  await writeJsonFileWithBackup(path, config);
124
+
125
+ // Write (or refresh) the websearch provider plugin when web search is
126
+ // routed through the gateway. The plugin id follows the provider name.
127
+ if (input.websearch !== undefined) {
128
+ await writeWebsearchPlugin(opencodeSpec.resolveDir?.() ?? join(homeDir(), ".config", "opencode"), providerId);
129
+ }
130
+
114
131
  return otherProviders.length > 0
115
132
  ? [
116
- `Config already has other provider(s): ${otherProviders.join(", ")}. They were kept as-is — only cartethyia was written/updated.`,
133
+ `Config already has other provider(s): ${otherProviders.join(", ")}. They were kept as-is — only ${providerId} was written/updated.`,
117
134
  ]
118
135
  : undefined;
119
136
  },
@@ -122,29 +139,39 @@ export const opencodeSpec: InjectorSpec = {
122
139
  const config = (await readJsonFile(path)) as OpencodeConfig | null;
123
140
  if (!config) return false;
124
141
 
142
+ const providerId = config.websearch?.provider ?? "cartethyia";
143
+
125
144
  if (config.provider) {
126
- delete config.provider.cartethyia;
145
+ delete config.provider[providerId];
127
146
  if (Object.keys(config.provider).length === 0) delete config.provider;
128
147
  }
129
148
 
130
- if (typeof config.model === "string" && config.model.startsWith("cartethyia/")) {
149
+ if (typeof config.model === "string" && config.model.startsWith(`${providerId}/`)) {
131
150
  delete config.model;
132
151
  }
133
152
 
134
- if (config.agent?.explorer?.model?.startsWith("cartethyia/")) {
153
+ if (config.agent?.explorer?.model?.startsWith(`${providerId}/`)) {
135
154
  delete config.agent.explorer;
136
155
  if (Object.keys(config.agent).length === 0) delete config.agent;
137
156
  }
138
157
 
158
+ if (config.websearch) {
159
+ delete config.websearch;
160
+ }
161
+
139
162
  await writeJsonFileWithBackup(path, config);
163
+
164
+ // Remove the generated websearch plugin when present.
165
+ await removeWebsearchPlugin(opencodeSpec.resolveDir?.() ?? join(homeDir(), ".config", "opencode"), providerId);
140
166
  },
141
167
 
142
168
  download(input) {
143
169
  const activeModel = input.activeModel ?? input.modelIds[0] ?? "";
144
170
  const subagentModel = input.subagentModel ?? input.modelIds[0] ?? activeModel;
171
+ const providerId = input.websearch ?? "cartethyia";
145
172
  const config: OpencodeConfig = {
146
173
  provider: {
147
- cartethyia: {
174
+ [providerId]: {
148
175
  npm: input.sdk ?? DEFAULT_SDK,
149
176
  options: {
150
177
  baseURL: ensureV1Suffix(input.endpoint),
@@ -153,12 +180,13 @@ export const opencodeSpec: InjectorSpec = {
153
180
  models: buildOpencodeModels(input.modelIds),
154
181
  },
155
182
  },
156
- model: activeModel === "" ? "" : `cartethyia/${activeModel}`,
183
+ model: activeModel === "" ? "" : `${providerId}/${activeModel}`,
184
+ ...(input.websearch !== undefined ? { websearch: { provider: input.websearch } } : {}),
157
185
  agent: {
158
186
  explorer: {
159
187
  description: "Fast explorer subagent",
160
188
  mode: "subagent",
161
- model: `cartethyia/${subagentModel}`,
189
+ model: `${providerId}/${subagentModel}`,
162
190
  },
163
191
  },
164
192
  };
@@ -935,6 +935,8 @@ export interface ProviderRoutingResponse {
935
935
  readonly skipProxy: boolean;
936
936
  /** Built-in API-key upstream User-Agent; OAuth and custom providers retain their own identity. */
937
937
  readonly userAgent: string;
938
+ /** Operator-set custom User-Agent override; `null` = no override (native identity is used). */
939
+ readonly customUserAgent: string | null;
938
940
  }
939
941
 
940
942
  /**
@@ -966,4 +968,6 @@ export interface UpdateProviderRoutingRequest {
966
968
  readonly skipProxy?: boolean;
967
969
  /** Built-in API-key upstream User-Agent. */
968
970
  readonly userAgent?: string;
971
+ /** Operator-set custom User-Agent override; `null` clears it back to native. */
972
+ readonly customUserAgent?: string | null;
969
973
  }
@@ -126,6 +126,7 @@ const probeModelBody = t.Object({
126
126
  reasoningEffort: t.Optional(literalUnion(PROBE_REASONING_EFFORTS)),
127
127
  maxOutputTokens: t.Optional(t.Integer({ minimum: 1, maximum: 131072 })),
128
128
  stream: t.Optional(t.Boolean()),
129
+ surface: t.Optional(literalUnion(["default", "cosy", "grpc", "ide"])),
129
130
  });
130
131
  const byokConnectionTestBody = t.Object({
131
132
  baseUrl: t.String(),
@@ -100,6 +100,13 @@ function createProviderDetailOperations(deps: ProviderDetailConfig) {
100
100
  ) {
101
101
  throw new ConsoleDomainError("invalid_request", 400, "userAgent must be a header value up to 4096 characters");
102
102
  }
103
+ if (
104
+ patch.customUserAgent !== undefined &&
105
+ patch.customUserAgent !== null &&
106
+ (patch.customUserAgent.length > 4096 || /[\u0000-\u001f\u007f]/.test(patch.customUserAgent))
107
+ ) {
108
+ throw new ConsoleDomainError("invalid_request", 400, "customUserAgent must be a header value up to 4096 characters, or null");
109
+ }
103
110
  const updated = await deps.store.updateRouting(providerId, a.tenantId, patch);
104
111
  await deps.auditSink?.record({
105
112
  access: a,
@@ -157,6 +164,7 @@ const updateRoutingBody = t.Object({
157
164
  usageFloorUnit: t.Optional(t.Union([t.Literal("credit"), t.Literal("percent")])),
158
165
  skipProxy: t.Optional(t.Boolean()),
159
166
  userAgent: t.Optional(t.String({ minLength: 0, maxLength: 4096 })),
167
+ customUserAgent: t.Optional(t.Nullable(t.String({ minLength: 0, maxLength: 4096 }))),
160
168
  });
161
169
 
162
170
 
@@ -41,6 +41,7 @@ function mapRow(row: typeof providerRoutingSettings.$inferSelect): ProviderRouti
41
41
  enabled: row.enabled,
42
42
  skipProxy: row.skipProxy,
43
43
  userAgent: row.userAgent,
44
+ customUserAgent: row.customUserAgent,
44
45
  };
45
46
  }
46
47
 
@@ -94,6 +95,7 @@ export class DrizzleProviderDetailStore implements ProviderDetailStore {
94
95
  enabled: false,
95
96
  skipProxy: defaultSkipProxy(providerId),
96
97
  userAgent: buildClaudeCliUserAgent(),
98
+ customUserAgent: null,
97
99
  };
98
100
  }
99
101
  async updateRouting(
@@ -116,6 +118,7 @@ export class DrizzleProviderDetailStore implements ProviderDetailStore {
116
118
  enabled: patch.enabled ?? false,
117
119
  skipProxy: patch.skipProxy ?? defaultSkipProxy(providerId),
118
120
  userAgent: patch.userAgent ?? buildClaudeCliUserAgent(),
121
+ customUserAgent: patch.customUserAgent ?? null,
119
122
  };
120
123
  const setClause: Record<string, unknown> = {};
121
124
  if (patch.strategy !== undefined) setClause.strategy = patch.strategy;
@@ -128,6 +131,7 @@ export class DrizzleProviderDetailStore implements ProviderDetailStore {
128
131
  if (patch.enabled !== undefined) setClause.enabled = patch.enabled;
129
132
  if (patch.skipProxy !== undefined) setClause.skipProxy = patch.skipProxy;
130
133
  if (patch.userAgent !== undefined) setClause.userAgent = patch.userAgent;
134
+ if (patch.customUserAgent !== undefined) setClause.customUserAgent = patch.customUserAgent;
131
135
  const upserted = await this.db
132
136
  .insert(providerRoutingSettings)
133
137
  .values(values)
@@ -48,6 +48,11 @@ import {
48
48
  fetchQoderDailyCheckin,
49
49
  isQoderCheckinProvider,
50
50
  } from "../../providers/integrations/qoder/qoder-checkin";
51
+ import {
52
+ claimQoderCredits,
53
+ QODER_CREDIT_PROVIDER_IDS,
54
+ type QoderCreditClaimResult,
55
+ } from "../../providers/integrations/qoder/claim-qoder-credits";
51
56
  import {
52
57
  DrizzleQoderNativeIdentityStore,
53
58
  type QoderNativeIdentityStore,
@@ -1136,6 +1141,75 @@ export function registerAccountQuotaTenantRoutes(
1136
1141
  : (result.detail ?? "Claim it in the ZCode app"),
1137
1142
  };
1138
1143
  })
1144
+ // Qoder daily-credits claim is likewise an explicit operator action: the
1145
+ // CREDITS campaign only surfaces with the real UMID headers, so the claim
1146
+ // mints/binds a native identity first (same store the check-in worker uses).
1147
+ .post("/accounts/:id/claim-qoder-credits", async ({ request, params, set }) => {
1148
+ const access = requireTenantScope(accessResolver(request), "dashboard:write");
1149
+ const rows = await db
1150
+ .select()
1151
+ .from(providerAccounts)
1152
+ .where(
1153
+ and(eq(providerAccounts.id, params.id), eq(providerAccounts.tenantId, access.tenantId)),
1154
+ )
1155
+ .limit(1);
1156
+ const account = rows[0];
1157
+ if (!account) {
1158
+ set.status = 404;
1159
+ return { error: `Account ${params.id} not found`, code: "not_found" };
1160
+ }
1161
+ if (!QODER_CREDIT_PROVIDER_IDS.includes(account.providerId as (typeof QODER_CREDIT_PROVIDER_IDS)[number])) {
1162
+ set.status = 400;
1163
+ return {
1164
+ error: "Daily credits are only claimable for qoder accounts",
1165
+ code: "qoder_credits_unsupported",
1166
+ };
1167
+ }
1168
+ let result: QoderCreditClaimResult;
1169
+ try {
1170
+ const credential = await refreshDeps.resolveCredential(account.providerId as ProviderId, account.id);
1171
+ const fetcher = networkBindingFactory
1172
+ ? await dailyCheckinEgressForPass({ db, networkBindingFactory })(account.id, access.tenantId)
1173
+ : globalThis.fetch;
1174
+ result = await claimQoderCredits({ db, providerId: account.providerId, accountId: account.id, credential, fetcher });
1175
+ } catch {
1176
+ set.status = 502;
1177
+ return {
1178
+ ok: false, status: 502,
1179
+ data: { id: account.id, state: "error", amount: 0, detail: "Qoder credit claim request failed" },
1180
+ message: "Qoder credit claim request failed", code: "qoder_credit_claim_failed",
1181
+ };
1182
+ }
1183
+ if (result.state === "claimed") {
1184
+ // Cold readers must not resurrect the balance from before the grant.
1185
+ await db.update(providerAccounts).set({ quotaSnapshot: null }).where(
1186
+ and(eq(providerAccounts.id, account.id), eq(providerAccounts.tenantId, access.tenantId)),
1187
+ );
1188
+ await invalidateQuotaCacheForAccount(account.id, redis);
1189
+ await snapshotInvalidator?.invalidate();
1190
+ }
1191
+ if (result.state === "error") {
1192
+ set.status = 502;
1193
+ return {
1194
+ ok: false,
1195
+ status: 502,
1196
+ data: { id: account.id, ...result },
1197
+ message: result.detail ?? "Qoder credit claim failed",
1198
+ code: "qoder_credit_claim_failed",
1199
+ };
1200
+ }
1201
+ return {
1202
+ ok: true,
1203
+ status: 200,
1204
+ data: { id: account.id, ...result },
1205
+ message:
1206
+ result.state === "nothing"
1207
+ ? "No CREDITS campaign to claim"
1208
+ : result.state === "claimed"
1209
+ ? `Claimed ${result.amount} credits`
1210
+ : "CREDITS campaign already claimed",
1211
+ };
1212
+ })
1139
1213
  .post("/accounts/:id/activity-report", async ({ request, params, set }) => {
1140
1214
  const access = requireTenantScope(accessResolver(request), "dashboard:write");
1141
1215
  const rows = await db
@@ -153,7 +153,8 @@ function telemetryEventRow(event: TelemetryEventInput) {
153
153
  event.usage?.estimated_cost === null || event.usage === undefined
154
154
  ? null
155
155
  : String(event.usage.estimated_cost),
156
- // Buddy-meter credit; NULL when the upstream reported no credit field.
156
+ // Provider-billed credit (Tencent buddy-meter `credit`, Qoder `credits`);
157
+ // NULL when the upstream reported no credit field.
157
158
  // Numeric columns are string-typed in Drizzle, like `estimatedCostUsd`.
158
159
  creditUsed: event.usage?.credit_used != null ? String(event.usage.credit_used) : null,
159
160
  resolveMs: event.resolveMs ?? null,
@@ -11,18 +11,27 @@ import { applySqlMigrations, createPgHandle, getPool, isPgHandle } from "./db-pg
11
11
  import { applySqliteMigrations, buildSqliteHandle, createSqliteDatabase } from "./db-sqlite";
12
12
 
13
13
  /**
14
- * True when `error` is a Postgres unique-violation (SQLSTATE 23505).
14
+ * True when `error` is a unique-violation from either supported dialect:
15
+ *
16
+ * - Postgres: SQLSTATE 23505 (`code: "23505"`).
17
+ * - SQLite: `UNIQUE constraint failed: <index>` (message text) or the
18
+ * driver's `SQLITE_CONSTRAINT_UNIQUE` code (19).
15
19
  *
16
20
  * Lives at the persistence boundary because it is a fact about the driver's
17
21
  * error shape, not about any one domain. It walks a bounded `cause` chain
18
- * because Drizzle wraps driver errors, so the SQLSTATE is not always on the
19
- * error the caller catches.
22
+ * because Drizzle wraps driver errors, so the code/message is not always on
23
+ * the error the caller catches.
20
24
  */
21
25
  export function isUniqueViolation(error: unknown): boolean {
22
26
  let current: unknown = error;
23
27
  for (let depth = 0; depth < 3; depth += 1) {
24
28
  if (typeof current !== "object" || current === null) return false;
25
- if ("code" in current && current.code === "23505") return true;
29
+ const record = current as { code?: unknown; message?: unknown };
30
+ const code = typeof record.code === "string" ? record.code : "";
31
+ const message = typeof record.message === "string" ? record.message : "";
32
+ if (code === "23505" || code === "SQLITE_CONSTRAINT_UNIQUE") return true;
33
+ if (message.includes("UNIQUE constraint failed:")) return true;
34
+ if (code === "19" && message.includes("constraint")) return true;
26
35
  if (!("cause" in current)) return false;
27
36
  current = current.cause;
28
37
  }
@@ -793,6 +793,12 @@ export const providerRoutingSettings = pgTable(
793
793
  // row that never set one is `buildClaudeCliUserAgent()` in
794
794
  // providers/operations/custom-cli-headers.ts, which this mirrors.
795
795
  userAgent: text("user_agent").notNull().default("claude-cli/2.1.293 (external, cli)"),
796
+ // Operator-set custom User-Agent, stored separately from the provider's
797
+ // native client identity (`user_agent`). NULL = no override — dispatch
798
+ // uses the native identity. A non-NULL value is an explicit override
799
+ // applied at the request edge, even when the provider would otherwise
800
+ // stamp its own identity.
801
+ customUserAgent: text("custom_user_agent"),
796
802
  // When true, this provider's requests always dial direct. When false,
797
803
  // dispatch automatically selects among every active network pool the
798
804
  // tenant owns (least-loaded/non-cooldown) — never an admin-pinned pool.
@@ -1458,7 +1464,7 @@ export const telemetryEvents = pgTable(
1458
1464
  outputTokens: integer("output_tokens"),
1459
1465
  reasoningTokens: integer("reasoning_tokens"),
1460
1466
  estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }),
1461
- /** Provider-billed credits (Tencent buddy-meter `credit`); NULL when unreported. */
1467
+ /** Provider-billed credits (Tencent buddy-meter `credit`, Qoder `credits`); NULL when unreported. */
1462
1468
  creditUsed: numeric("credit_used", { precision: 12, scale: 4 }),
1463
1469
  /**
1464
1470
  * Gateway-side time before the upstream dispatch began: routing,
@@ -370,6 +370,13 @@ export interface CanonicalTerminalEvent {
370
370
  readonly response_id?: string;
371
371
  /** Event timestamp in milliseconds since epoch (optional for profiling). */
372
372
  readonly timestamp?: number;
373
+ /**
374
+ * Provider-specific upstream metadata about the model that actually served
375
+ * the turn — its native model id, supply target, tier, etc. Not a client
376
+ * surface: probes read it to report what the upstream really ran when the
377
+ * transport's model echo is an opaque id.
378
+ */
379
+ readonly provider_metadata?: Record<string, unknown>;
373
380
  /**
374
381
  * Set by an adapter whose turn is not finished: the stream ended because the
375
382
  * model asked for a tool the client must answer, and the provider's
@@ -287,7 +287,26 @@ export function buildGeminiPayload(
287
287
  });
288
288
  const payload: Record<string, unknown> = { contents };
289
289
  if (systemParts.length > 0) payload["systemInstruction"] = { role: "user", parts: systemParts };
290
- const functionTools = (request.tools ?? []).filter((t) => t.name);
290
+ // Gemini's native hosted tools are declared as bare object keys, not
291
+ // function declarations: `{ googleSearch: {} }` (and codeExecution/urlContext)
292
+ // enable Google-backed grounding. Accept both spellings the gateway's search
293
+ // adapters use (`web_search` tool_type and `googleSearch` name) and map them
294
+ // to the native declaration so the upstream actually grounds the query.
295
+ const HOSTED_TOOL_NAMES = new Set(["googleSearch", "codeExecution", "urlContext"]);
296
+ const hostedTools: Record<string, Record<string, unknown>>[] = [];
297
+ for (const tool of request.tools ?? []) {
298
+ if (tool.name === "googleSearch" || tool.tool_type === "web_search") {
299
+ hostedTools.push({ googleSearch: {} });
300
+ } else if (tool.name === "codeExecution") {
301
+ hostedTools.push({ codeExecution: {} });
302
+ } else if (tool.name === "urlContext") {
303
+ hostedTools.push({ urlContext: {} });
304
+ }
305
+ }
306
+ const functionTools = (request.tools ?? []).filter(
307
+ (t) => t.name && !HOSTED_TOOL_NAMES.has(t.name) && t.tool_type !== "web_search",
308
+ );
309
+ if (hostedTools.length > 0) payload["tools"] = hostedTools;
291
310
  if (functionTools.length > 0) {
292
311
  payload["tools"] = [
293
312
  {
@@ -73,9 +73,23 @@ export function trackToolCall(
73
73
  fallbackIndex?: number,
74
74
  ): { index: number; isFirst: boolean; state: ToolCallState } {
75
75
  const priorIndex = tracker.indexById.get(tool.id);
76
+ // A wire index is only honored when this id already owns it, or when it is
77
+ // not yet claimed by another call. Upstream Qoder parallel calls frequently
78
+ // repeat the same wire index (0,0) for different ids, which would otherwise
79
+ // collapse two calls into one at the encoder (`Map` keyed by index). When
80
+ // the wire index collides with another id, synthesize a fresh index so every
81
+ // logical call keeps a distinct wire position.
76
82
  const index =
77
83
  tool.index !== undefined && Number.isInteger(tool.index) && tool.index >= 0
78
- ? tool.index
84
+ ? (() => {
85
+ if (priorIndex !== undefined) return priorIndex;
86
+ for (const [id, assigned] of tracker.indexById) {
87
+ if (id !== tool.id && assigned === tool.index) {
88
+ return tracker.nextIndex++;
89
+ }
90
+ }
91
+ return tool.index;
92
+ })()
79
93
  : (priorIndex ?? fallbackIndex ?? tracker.nextIndex++);
80
94
  tracker.indexById.set(tool.id, index);
81
95
  tracker.nextIndex = Math.max(tracker.nextIndex, index + 1);
@@ -202,7 +202,10 @@ export const PROVIDER_CAPABILITIES = {
202
202
  },
203
203
  codex: {
204
204
  loadAdapter: async () => (await import("./integrations/codex/codex")).createCodexAdapter({ provider_id: "codex" }),
205
- loadModels: async () => (await import("./integrations/codex/codex")).CODEX_MODELS,
205
+ loadModels: async () => {
206
+ const { CODEX_MODELS, CODEX_SEARCH_MODELS } = await import("./integrations/codex/codex");
207
+ return [...CODEX_MODELS, ...CODEX_SEARCH_MODELS];
208
+ },
206
209
  loadAuthentication: oauthCapability(() => import("./integrations/codex/codex-oauth"), "codexOAuthClient", { withRefresher: true }),
207
210
  loadQuotaCollector: quotaCapability(() => import("./integrations/codex/codex-quota"), "fetchCodexQuota"),
208
211
  loadModelDiscovery: async () => async ({ credential, fetcher }) =>
@@ -235,7 +238,10 @@ export const PROVIDER_CAPABILITIES = {
235
238
  },
236
239
  antigravity: {
237
240
  loadAdapter: async () => (await import("./integrations/antigravity/antigravity")).antigravityAdapter,
238
- loadModels: async () => (await import("./integrations/antigravity/antigravity")).ANTIGRAVITY_MODELS,
241
+ loadModels: async () => {
242
+ const { ANTIGRAVITY_MODELS, ANTIGRAVITY_SEARCH_MODELS } = await import("./integrations/antigravity/antigravity");
243
+ return [...ANTIGRAVITY_MODELS, ...ANTIGRAVITY_SEARCH_MODELS];
244
+ },
239
245
  loadAuthentication: oauthCapability(() => import("./integrations/antigravity/antigravity-oauth"), "antigravityOAuthClient", { withRefresher: true }),
240
246
  loadQuotaCollector: quotaCapability(() => import("./integrations/antigravity/antigravity-quota"), "fetchAntigravityQuota"),
241
247
  loadModelDiscovery: async () => async ({ baseUrl, credential, fetcher }) => {