@oh-my-pi/pi-catalog 16.1.22 → 16.2.0

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.
@@ -55,7 +55,7 @@ export function allowsUnauthenticatedCatalogDiscovery(descriptor: CatalogProvide
55
55
  * - Every entry is a member of `KnownProvider`.
56
56
  * - `createModelManagerOptions` present (and not `specialModelManager`) ⇒
57
57
  * appears in `PROVIDER_DESCRIPTORS` for runtime model discovery.
58
- * - `catalogDiscovery` present ⇒ participates in `generate-models.ts`.
58
+ * - \`catalogDiscovery\` present ⇒ participates in \`generate-models.ts\`.
59
59
  */
60
60
  export interface ProviderCatalogEntry {
61
61
  readonly id: string;
@@ -14,6 +14,7 @@ import {
14
14
  anthropicModelManagerOptions,
15
15
  cerebrasModelManagerOptions,
16
16
  cloudflareAiGatewayModelManagerOptions,
17
+ coreWeaveModelManagerOptions,
17
18
  deepseekModelManagerOptions,
18
19
  firepassModelManagerOptions,
19
20
  fireworksModelManagerOptions,
@@ -49,7 +50,12 @@ import {
49
50
  zenmuxModelManagerOptions,
50
51
  zhipuCodingPlanModelManagerOptions,
51
52
  } from "./openai-compat";
52
- import { cursorModelManagerOptions, devinModelManagerOptions, zaiModelManagerOptions } from "./special";
53
+ import {
54
+ cursorModelManagerOptions,
55
+ devinModelManagerOptions,
56
+ gitLabDuoWorkflowModelManagerOptions,
57
+ zaiModelManagerOptions,
58
+ } from "./special";
53
59
 
54
60
  export const CATALOG_PROVIDERS = [
55
61
  {
@@ -141,6 +147,13 @@ export const CATALOG_PROVIDERS = [
141
147
  defaultModel: "duo-chat-opus-4-6",
142
148
  envVars: ["GITLAB_TOKEN"],
143
149
  },
150
+ {
151
+ id: "gitlab-duo-agent",
152
+ defaultModel: "claude_sonnet_4_6_vertex",
153
+ envVars: ["GITLAB_TOKEN"],
154
+ createModelManagerOptions: (config: ModelManagerConfig) => gitLabDuoWorkflowModelManagerOptions(config),
155
+ dynamicModelsAuthoritative: true,
156
+ },
144
157
  {
145
158
  id: "google",
146
159
  defaultModel: "gemini-3.1-pro-preview",
@@ -375,6 +388,13 @@ export const CATALOG_PROVIDERS = [
375
388
  oauthProvider: "wafer-serverless",
376
389
  },
377
390
  },
391
+ {
392
+ id: "coreweave",
393
+ defaultModel: "openai/gpt-oss-120b",
394
+ envVars: ["COREWEAVE_API_KEY", "WANDB_API_KEY"],
395
+ createModelManagerOptions: (config: ModelManagerConfig) => coreWeaveModelManagerOptions(config),
396
+ catalogDiscovery: { label: "CoreWeave Serverless Inference" },
397
+ },
378
398
  {
379
399
  id: "xai",
380
400
  defaultModel: "grok-4-fast-non-reasoning",
@@ -8,8 +8,9 @@ import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-mo
8
8
  import { isGlmVisionModelId, isGrokReasoningEffortCapable, isReasoningGlmModelId } from "../identity/family";
9
9
  import type { ModelManagerOptions } from "../model-manager";
10
10
  import { getBundledModels } from "../models";
11
- import type { Api, FetchImpl, Model, ModelSpec, Provider, ThinkingConfig } from "../types";
11
+ import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types";
12
12
  import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
13
+ import { coreWeaveProjectHeaders } from "../wire/coreweave";
13
14
  import {
14
15
  COPILOT_API_HEADERS,
15
16
  getGitHubCopilotBaseUrl,
@@ -470,7 +471,19 @@ function isLikelyNanoGptTextModelId(id: string): boolean {
470
471
  return !NANO_GPT_NON_TEXT_MODEL_TOKENS.some(token => normalized.includes(token));
471
472
  }
472
473
 
473
- type SimpleProviderConfig = { apiKey?: string; baseUrl?: string; fetch?: FetchImpl };
474
+ type SimpleProviderDiscoveryHeaders = Record<string, string> | (() => Record<string, string> | undefined);
475
+ type SimpleProviderConfig = {
476
+ apiKey?: string;
477
+ baseUrl?: string;
478
+ fetch?: FetchImpl;
479
+ headers?: SimpleProviderDiscoveryHeaders;
480
+ };
481
+
482
+ function resolveSimpleProviderHeaders(
483
+ headers: SimpleProviderDiscoveryHeaders | undefined,
484
+ ): Record<string, string> | undefined {
485
+ return typeof headers === "function" ? headers() : headers;
486
+ }
474
487
 
475
488
  export function createSimpleOpenAICompletionsOptions(
476
489
  providerId: Parameters<typeof getBundledModels>[0],
@@ -489,6 +502,7 @@ export function createSimpleOpenAICompletionsOptions(
489
502
  provider: providerId,
490
503
  baseUrl,
491
504
  apiKey,
505
+ headers: resolveSimpleProviderHeaders(config?.headers),
492
506
  mapModel: (entry, defaults) => {
493
507
  const reference = references.get(defaults.id);
494
508
  return mapWithBundledReference(entry, defaults, reference);
@@ -516,6 +530,7 @@ function createSimpleOpenAIResponsesOptions(
516
530
  provider: providerId,
517
531
  baseUrl,
518
532
  apiKey,
533
+ headers: resolveSimpleProviderHeaders(config?.headers),
519
534
  mapModel: (entry, defaults) => {
520
535
  const reference = references.get(defaults.id);
521
536
  return mapWithBundledReference(entry, defaults, reference);
@@ -1051,7 +1066,7 @@ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]
1051
1066
  * Single source of truth for the curated to Model fan-in, consumed by both
1052
1067
  * - {@link xaiOAuthModelManagerOptions} (runtime static seed handed to the model
1053
1068
  * manager so the picker is populated on a fresh login), and
1054
- * - `packages/ai/scripts/generate-models.ts` (bundles the same entries into
1069
+ * - \`packages/catalog/scripts/generate-models.ts\` (bundles the same entries into
1055
1070
  * `models.json`, so the synchronous `ModelRegistry.#loadModels()` boot path
1056
1071
  * sees `xai-oauth` without waiting for a refresh — fixes the boot-time
1057
1072
  * default-model reset when `modelRoles.default = "xai-oauth/<id>"`).
@@ -1100,7 +1115,7 @@ export function xaiOAuthModelManagerOptions(
1100
1115
  // Static seed handed to the runtime model manager so the picker populates on
1101
1116
  // a fresh login even before `fetchDynamicModels` fires (it is gated on
1102
1117
  // `config.apiKey` at construction time, and OAuth tokens resolve later via
1103
- // AuthStorage). `generate-models.ts` calls the same builder so `models.json`
1118
+ // AuthStorage). \`generate-models.ts\` calls the same builder so \`models.json\`
1104
1119
  // carries these entries too — making the synchronous `#loadModels()` boot
1105
1120
  // path honor `modelRoles.default = "xai-oauth/<id>"` without `await refresh()`.
1106
1121
  const staticModels = buildXaiOAuthStaticSeed(resolvedBaseUrl);
@@ -2480,6 +2495,25 @@ export function togetherModelManagerOptions(
2480
2495
  return createSimpleOpenAICompletionsOptions("together", "https://api.together.xyz/v1", config);
2481
2496
  }
2482
2497
 
2498
+ // ---------------------------------------------------------------------------
2499
+ // 15.5 CoreWeave Serverless Inference
2500
+ // ---------------------------------------------------------------------------
2501
+
2502
+ export interface CoreWeaveModelManagerConfig {
2503
+ apiKey?: string;
2504
+ baseUrl?: string;
2505
+ fetch?: FetchImpl;
2506
+ }
2507
+
2508
+ export function coreWeaveModelManagerOptions(
2509
+ config?: CoreWeaveModelManagerConfig,
2510
+ ): ModelManagerOptions<"openai-completions"> {
2511
+ return createSimpleOpenAICompletionsOptions("coreweave", "https://api.inference.wandb.ai/v1", {
2512
+ ...config,
2513
+ headers: () => coreWeaveProjectHeaders(Bun.env),
2514
+ });
2515
+ }
2516
+
2483
2517
  // ---------------------------------------------------------------------------
2484
2518
  // 16. Moonshot
2485
2519
  // ---------------------------------------------------------------------------
@@ -2783,6 +2817,231 @@ export interface LiteLLMModelManagerConfig {
2783
2817
  fetch?: FetchImpl;
2784
2818
  }
2785
2819
 
2820
+ export interface FetchLiteLLMRichModelsOptions<TApi extends Api> {
2821
+ api: TApi;
2822
+ provider: Provider;
2823
+ baseUrl: string;
2824
+ apiKey?: string;
2825
+ headers?: Record<string, string>;
2826
+ fetch?: FetchImpl;
2827
+ signal?: AbortSignal;
2828
+ referenceResolver?: (modelId: string) => ModelSpec<TApi> | undefined;
2829
+ }
2830
+
2831
+ type LiteLLMRichModelEntry = Record<string, unknown>;
2832
+
2833
+ const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const;
2834
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000;
2835
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
2836
+ const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
2837
+
2838
+ export function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string {
2839
+ const trimmed = baseUrl.trim().replace(/\/+$/g, "");
2840
+ if (!trimmed) {
2841
+ return "";
2842
+ }
2843
+ try {
2844
+ const parsed = new URL(trimmed);
2845
+ const path = parsed.pathname.replace(/\/+$/g, "");
2846
+ parsed.pathname = path.endsWith("/v1") ? path.slice(0, -3) || "/" : path || "/";
2847
+ const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2848
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2849
+ } catch {
2850
+ return trimmed.replace(/\/v1$/, "");
2851
+ }
2852
+ }
2853
+
2854
+ function normalizeLiteLLMRuntimeBaseUrl(baseUrl: string): string {
2855
+ const trimmed = baseUrl.trim();
2856
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2857
+ }
2858
+
2859
+ function toNonEmptyString(value: unknown): string | undefined {
2860
+ if (typeof value !== "string") {
2861
+ return undefined;
2862
+ }
2863
+ const trimmed = value.trim();
2864
+ return trimmed.length > 0 ? trimmed : undefined;
2865
+ }
2866
+
2867
+ function extractLiteLLMRichEntries(payload: unknown): LiteLLMRichModelEntry[] | null {
2868
+ if (Array.isArray(payload)) {
2869
+ return payload.flatMap(entry => (isRecord(entry) ? [entry] : []));
2870
+ }
2871
+ if (!isRecord(payload)) {
2872
+ return null;
2873
+ }
2874
+ for (const candidate of [payload.data, payload.models, payload.result, payload.items]) {
2875
+ if (candidate === undefined) {
2876
+ continue;
2877
+ }
2878
+ const entries = extractLiteLLMRichEntries(candidate);
2879
+ if (entries !== null) {
2880
+ return entries;
2881
+ }
2882
+ }
2883
+ return null;
2884
+ }
2885
+
2886
+ function getLiteLLMModelInfo(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2887
+ return isRecord(entry.model_info) ? entry.model_info : undefined;
2888
+ }
2889
+
2890
+ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2891
+ return isRecord(entry.litellm_params) ? entry.litellm_params : undefined;
2892
+ }
2893
+
2894
+ function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
2895
+ return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
2896
+ }
2897
+
2898
+ function getLiteLLMRichModelId(entry: LiteLLMRichModelEntry): string | undefined {
2899
+ return (
2900
+ toNonEmptyString(entry.model_group) ??
2901
+ toNonEmptyString(entry.model_name) ??
2902
+ toNonEmptyString(entry.id) ??
2903
+ toNonEmptyString(getLiteLLMParams(entry)?.model)
2904
+ );
2905
+ }
2906
+
2907
+ function getSupportedOpenAIParams(entry: LiteLLMRichModelEntry): string[] | undefined {
2908
+ const value = getLiteLLMMetadataValue(entry, "supported_openai_params");
2909
+ if (!Array.isArray(value)) {
2910
+ return undefined;
2911
+ }
2912
+ return value.flatMap(item => (typeof item === "string" ? [item] : []));
2913
+ }
2914
+
2915
+ function mapLiteLLMRichEntry<TApi extends Api>(
2916
+ entry: LiteLLMRichModelEntry,
2917
+ options: FetchLiteLLMRichModelsOptions<TApi>,
2918
+ runtimeBaseUrl: string,
2919
+ ): ModelSpec<TApi> | null {
2920
+ const id = getLiteLLMRichModelId(entry);
2921
+ if (!id) {
2922
+ return null;
2923
+ }
2924
+ const reference = options.referenceResolver?.(id);
2925
+ const modelName = toNonEmptyString(entry.model_name);
2926
+ const contextWindow = toPositiveNumber(
2927
+ getLiteLLMMetadataValue(entry, "max_input_tokens"),
2928
+ reference?.contextWindow ?? OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW,
2929
+ );
2930
+ const maxTokens = toPositiveNumber(
2931
+ getLiteLLMMetadataValue(entry, "max_output_tokens"),
2932
+ reference?.maxTokens ?? Math.min(contextWindow, OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS),
2933
+ );
2934
+ const supportsVision = getLiteLLMMetadataValue(entry, "supports_vision");
2935
+ const supportsReasoning = getLiteLLMMetadataValue(entry, "supports_reasoning");
2936
+ const supportedOpenAIParams = getSupportedOpenAIParams(entry);
2937
+ const supportsFunctionCalling = getLiteLLMMetadataValue(entry, "supports_function_calling");
2938
+ const supportsTools =
2939
+ supportsFunctionCalling === true
2940
+ ? true
2941
+ : supportsFunctionCalling === false
2942
+ ? false
2943
+ : supportedOpenAIParams !== undefined
2944
+ ? supportedOpenAIParams.some(param =>
2945
+ ["tools", "tool_choice", "functions", "function_call"].includes(param),
2946
+ )
2947
+ : reference?.supportsTools;
2948
+ const compat: OpenAICompat = {
2949
+ ...(reference?.compat ?? {}),
2950
+ supportsStore: false,
2951
+ supportsDeveloperRole: false,
2952
+ ...(supportedOpenAIParams !== undefined
2953
+ ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
2954
+ : {}),
2955
+ };
2956
+ return {
2957
+ id,
2958
+ name: modelName && modelName !== id ? modelName : (reference?.name ?? id),
2959
+ api: options.api,
2960
+ provider: options.provider,
2961
+ baseUrl: runtimeBaseUrl,
2962
+ contextWindow,
2963
+ maxTokens,
2964
+ input:
2965
+ supportsVision === true
2966
+ ? ["text", "image"]
2967
+ : supportsVision === false
2968
+ ? ["text"]
2969
+ : (reference?.input ?? ["text"]),
2970
+ reasoning: typeof supportsReasoning === "boolean" ? supportsReasoning : (reference?.reasoning ?? false),
2971
+ thinking: reference?.thinking,
2972
+ cost: reference?.cost ?? UNKNOWN_PROXY_COST,
2973
+ ...(supportsTools !== undefined ? { supportsTools } : {}),
2974
+ compat: compat as ModelSpec<TApi>["compat"],
2975
+ };
2976
+ }
2977
+
2978
+ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
2979
+ endpoint: string,
2980
+ options: FetchLiteLLMRichModelsOptions<TApi>,
2981
+ managementBaseUrl: string,
2982
+ runtimeBaseUrl: string,
2983
+ ): Promise<ModelSpec<TApi>[] | null> {
2984
+ const fetchImpl = options.fetch ?? globalThis.fetch;
2985
+ const requestHeaders: Record<string, string> = {
2986
+ Accept: "application/json",
2987
+ ...options.headers,
2988
+ };
2989
+ if (options.apiKey) {
2990
+ requestHeaders.Authorization = `Bearer ${options.apiKey}`;
2991
+ }
2992
+ let response: Response;
2993
+ try {
2994
+ response = await fetchImpl(`${managementBaseUrl}${endpoint}`, {
2995
+ method: "GET",
2996
+ headers: requestHeaders,
2997
+ signal: options.signal,
2998
+ });
2999
+ } catch {
3000
+ return null;
3001
+ }
3002
+ if (!response.ok) {
3003
+ return null;
3004
+ }
3005
+ let payload: unknown;
3006
+ try {
3007
+ payload = await response.json();
3008
+ } catch {
3009
+ return null;
3010
+ }
3011
+ const entries = extractLiteLLMRichEntries(payload);
3012
+ if (!entries || entries.length === 0) {
3013
+ return null;
3014
+ }
3015
+ const deduped = new Map<string, ModelSpec<TApi>>();
3016
+ for (const entry of entries) {
3017
+ const model = mapLiteLLMRichEntry(entry, options, runtimeBaseUrl);
3018
+ if (model) {
3019
+ deduped.set(model.id, model);
3020
+ }
3021
+ }
3022
+ if (deduped.size === 0) {
3023
+ return null;
3024
+ }
3025
+ return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
3026
+ }
3027
+
3028
+ export async function fetchLiteLLMRichModels<TApi extends Api>(
3029
+ options: FetchLiteLLMRichModelsOptions<TApi>,
3030
+ ): Promise<ModelSpec<TApi>[] | null> {
3031
+ const managementBaseUrl = normalizeLiteLLMManagementBaseUrl(options.baseUrl);
3032
+ const runtimeBaseUrl = normalizeLiteLLMRuntimeBaseUrl(options.baseUrl);
3033
+ if (!managementBaseUrl || !runtimeBaseUrl) {
3034
+ return null;
3035
+ }
3036
+ for (const endpoint of LITELLM_RICH_ENDPOINTS) {
3037
+ const models = await fetchLiteLLMRichEndpoint(endpoint, options, managementBaseUrl, runtimeBaseUrl);
3038
+ if (models) {
3039
+ return models;
3040
+ }
3041
+ }
3042
+ return null;
3043
+ }
3044
+
2786
3045
  export function litellmModelManagerOptions(
2787
3046
  config?: LiteLLMModelManagerConfig,
2788
3047
  ): ModelManagerOptions<"openai-completions"> {
@@ -2790,21 +3049,32 @@ export function litellmModelManagerOptions(
2790
3049
  const baseUrl = config?.baseUrl ?? Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1";
2791
3050
  return {
2792
3051
  providerId: "litellm",
2793
- cacheProviderId: `litellm:${Bun.hash(baseUrl).toString(36)}`,
2794
- // litellm is a local-only proxy whose /v1/models returns bare ids with no
2795
- // metadata, and it is never bundled in models.json (that would leak the
2796
- // machine's localhost catalog). It proxies known upstream models, so we
2797
- // enrich discovered ids against models.dev the same reference source the
2798
- // gateway providers (fireworks et al.) use — instead of a bundled map.
3052
+ cacheProviderId: `litellm:rich-v1:${Bun.hash(baseUrl).toString(36)}`,
3053
+ // litellm is a local-only proxy and is never bundled in models.json (that
3054
+ // would leak the machine's localhost catalog). Prefer the proxy's richer
3055
+ // management metadata, then fall back to /v1/models and enrich bare ids
3056
+ // against models.dev like the gateway providers (fireworks et al.) do.
2799
3057
  fetchDynamicModels: async () => {
2800
3058
  const modelsDevReferences = await loadModelsDevReferences<"openai-completions">(config?.fetch);
3059
+ const resolveReference = (id: string) => modelsDevReferences.get(id);
3060
+ const richModels = await fetchLiteLLMRichModels({
3061
+ api: "openai-completions",
3062
+ provider: "litellm",
3063
+ baseUrl,
3064
+ apiKey,
3065
+ fetch: config?.fetch,
3066
+ referenceResolver: resolveReference,
3067
+ signal: AbortSignal.timeout(10_000),
3068
+ });
3069
+ if (richModels && richModels.length > 0) {
3070
+ return richModels;
3071
+ }
2801
3072
  return fetchOpenAICompatibleModels({
2802
3073
  api: "openai-completions",
2803
3074
  provider: "litellm",
2804
3075
  baseUrl,
2805
3076
  apiKey,
2806
- mapModel: (entry, defaults) =>
2807
- mapWithBundledReference(entry, defaults, modelsDevReferences.get(defaults.id)),
3077
+ mapModel: (entry, defaults) => mapWithBundledReference(entry, defaults, resolveReference(defaults.id)),
2808
3078
  fetch: config?.fetch,
2809
3079
  });
2810
3080
  },
@@ -3653,6 +3923,19 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor
3653
3923
  openAiCompletionsDescriptor("cerebras", "cerebras", "https://api.cerebras.ai/v1"),
3654
3924
  // --- Together ---
3655
3925
  openAiCompletionsDescriptor("togetherai", "together", "https://api.together.xyz/v1"),
3926
+ // --- CoreWeave Serverless Inference ---
3927
+ openAiCompletionsDescriptor("wandb", "coreweave", "https://api.inference.wandb.ai/v1", {
3928
+ transformModel: model => {
3929
+ if (!model.id.startsWith("openai/gpt-oss-")) {
3930
+ return model;
3931
+ }
3932
+ return {
3933
+ ...model,
3934
+ reasoning: true,
3935
+ thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High] },
3936
+ };
3937
+ },
3938
+ }),
3656
3939
  // --- NVIDIA ---
3657
3940
  openAiCompletionsDescriptor("nvidia", "nvidia", "https://integrate.api.nvidia.com/v1", {
3658
3941
  defaultContextWindow: 131072,
@@ -1,7 +1,9 @@
1
1
  import { once } from "@oh-my-pi/pi-utils";
2
2
  import { fetchCodexModels } from "../discovery/codex";
3
3
  import type { DevinModelDiscoveryOptions } from "../discovery/devin";
4
+ import { buildGitLabDuoWorkflowFallbackModel, fetchGitLabDuoWorkflowModels } from "../discovery/gitlab-duo-workflow";
4
5
  import type { ModelManagerOptions } from "../model-manager";
6
+ import type { FetchImpl } from "../types";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
9
  // OpenAI Codex
@@ -58,6 +60,68 @@ export function cursorModelManagerOptions(config: CursorModelManagerConfig = {})
58
60
  const cursorDiscovery = once(() => import("../discovery/cursor"));
59
61
 
60
62
  // ---------------------------------------------------------------------------
63
+ // GitLab Duo Workflow
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export interface GitLabDuoWorkflowModelManagerConfig {
67
+ apiKey?: string;
68
+ baseUrl?: string;
69
+ fetch?: FetchImpl;
70
+ namespaceId?: string;
71
+ projectId?: string;
72
+ cwd?: string;
73
+ }
74
+
75
+ export function gitLabDuoWorkflowModelManagerOptions(
76
+ config: GitLabDuoWorkflowModelManagerConfig = {},
77
+ ): ModelManagerOptions<"gitlab-duo-agent"> {
78
+ const apiKey = config.apiKey;
79
+ return {
80
+ providerId: "gitlab-duo-agent",
81
+ // GitLab Duo discovery is credential- and namespace-specific
82
+ // (`aiChatAvailableModels(rootNamespaceId:)` also surfaces namespace-pinned
83
+ // models), so the default provider-id cache namespace would let a second
84
+ // account/namespace load the first one's authoritative model list at startup
85
+ // and skip refetching. Partition the cache by a non-reversible fingerprint of
86
+ // the exact inputs `fetchGitLabDuoWorkflowModels` resolves the namespace from
87
+ // (credential + base URL + namespace/project config + the same env vars + the
88
+ // effective workspace cwd whose git remote drives auto-discovery). Built-in
89
+ // discovery only passes apiKey/baseUrl/fetch, so the cwd/env terms — not the
90
+ // empty config fields — are what actually separate workspace A from B here.
91
+ // Falls back to the bare provider id when no credential is present.
92
+ ...(apiKey ? { cacheProviderId: gitLabDuoWorkflowModelCacheProviderId(apiKey, config) } : undefined),
93
+ dynamicModelsAuthoritative: true,
94
+ staticModels: [
95
+ buildGitLabDuoWorkflowFallbackModel("claude_sonnet_4_6_vertex", "Claude Sonnet 4.6 - Vertex", config.baseUrl),
96
+ ],
97
+ ...(apiKey
98
+ ? {
99
+ fetchDynamicModels: async () =>
100
+ fetchGitLabDuoWorkflowModels({
101
+ apiKey,
102
+ baseUrl: config.baseUrl,
103
+ fetch: config.fetch,
104
+ namespaceId: config.namespaceId,
105
+ projectId: config.projectId,
106
+ cwd: config.cwd,
107
+ }),
108
+ }
109
+ : undefined),
110
+ };
111
+ }
112
+
113
+ function gitLabDuoWorkflowModelCacheProviderId(apiKey: string, config: GitLabDuoWorkflowModelManagerConfig): string {
114
+ // Mirror the exact inputs `discoverGitLabDuoWorkflowNamespace` keys off: explicit
115
+ // namespace/project config OR the same env vars, then the git remote at the
116
+ // effective cwd. Built-in discovery leaves the config fields empty, so the env +
117
+ // resolved cwd terms are what actually distinguish two workspaces sharing a token.
118
+ const namespaceId = config.namespaceId ?? Bun.env.GITLAB_DUO_NAMESPACE_ID ?? "";
119
+ const projectId = config.projectId ?? Bun.env.GITLAB_DUO_PROJECT_ID ?? Bun.env.GITLAB_DUO_PROJECT_PATH ?? "";
120
+ const cwd = config.cwd ?? process.cwd();
121
+ const scope = [config.baseUrl ?? "", namespaceId, projectId, cwd].join("\u0000");
122
+ return `gitlab-duo-agent:${Bun.hash(`${apiKey}\u0000${scope}`).toString(36)}`;
123
+ }
124
+
61
125
  // Devin (Codeium Cascade)
62
126
  // ---------------------------------------------------------------------------
63
127
 
@@ -84,7 +148,6 @@ export function devinModelManagerOptions(config: DevinModelManagerConfig = {}):
84
148
  }
85
149
 
86
150
  const devinDiscovery = once(() => import("../discovery/devin"));
87
-
88
151
  // ---------------------------------------------------------------------------
89
152
  // Zai
90
153
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ export type KnownApi =
15
15
  | "google-vertex"
16
16
  | "ollama-chat"
17
17
  | "cursor-agent"
18
+ | "gitlab-duo-agent"
18
19
  | "devin-agent";
19
20
  export type Api = KnownApi | (string & {});
20
21
 
@@ -236,6 +237,28 @@ export interface OpenAICompat {
236
237
  * models).
237
238
  */
238
239
  replayReasoningContent?: boolean;
240
+ /**
241
+ * Send `preserve_thinking: true` so the Qwen3.6+ chat template renders
242
+ * `<think>...</think>` markup for EVERY assistant turn (not just turns
243
+ * after the last user message). Without it, the template strips the think
244
+ * block from older assistant turns:
245
+ *
246
+ * ```jinja
247
+ * {%- if (preserve_thinking is defined and preserve_thinking is true)
248
+ * or (loop.index0 > ns.last_query_index) %}
249
+ * <|im_start|>assistant\n<think>\n{rc}\n</think>\n\n{content}
250
+ * {%- else %}
251
+ * <|im_start|>assistant\n{content}
252
+ * ```
253
+ *
254
+ * The cache from the original generation has `<think>...</think>` tokens,
255
+ * so once a new user message arrives the prior assistant turns become
256
+ * "older" and the stripped re-render diverges — full prompt re-processing
257
+ * on SWA models (#3541). Default: auto-detected (Qwen thinking format on
258
+ * a local llama.cpp-style backend, paired with `replayReasoningContent`).
259
+ * Non-Qwen templates ignore the flag, so the auto-detection is safe.
260
+ */
261
+ qwenPreserveThinking?: boolean;
239
262
  /** Whether assistant tool-call messages must include non-empty content. Default: false. */
240
263
  requiresAssistantContentForToolCalls?: boolean;
241
264
  /** Whether the provider supports the `tool_choice` parameter. Default: true. */
@@ -246,6 +269,14 @@ export interface OpenAICompat {
246
269
  * to provider-default auto selection. Default: true.
247
270
  */
248
271
  supportsForcedToolChoice?: boolean;
272
+ /**
273
+ * Whether the chat-completions endpoint accepts the object form that pins one
274
+ * named function (`{ type: "function", function: { name } }`). Some
275
+ * OpenAI-compatible hosts such as llama.cpp only accept string
276
+ * `tool_choice` values; request builders downgrade a named force to
277
+ * `"required"` when this is false. Default: true.
278
+ */
279
+ supportsNamedToolChoice?: boolean;
249
280
  /**
250
281
  * Drop reasoning fields (`reasoning_effort`, OpenRouter `reasoning`) for
251
282
  * the request when `tool_choice` forces a tool call. Mirrors the Anthropic
@@ -443,11 +474,13 @@ export interface ResolvedOpenAISharedCompat {
443
474
  disableReasoningOnToolChoice: boolean;
444
475
  supportsToolChoice: boolean;
445
476
  supportsForcedToolChoice: boolean;
477
+ supportsNamedToolChoice: boolean;
446
478
  reasoningContentField?: OpenAICompat["reasoningContentField"];
447
479
  requiresReasoningContentForToolCalls: boolean;
448
480
  requiresReasoningContentForAllAssistantTurns: boolean;
449
481
  allowsSyntheticReasoningContentForToolCalls: boolean;
450
482
  replayReasoningContent: boolean;
483
+ qwenPreserveThinking: boolean;
451
484
  requiresThinkingAsText: boolean;
452
485
  requiresMistralToolIds: boolean;
453
486
  requiresToolResultName: boolean;
@@ -493,11 +526,13 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
493
526
  | "disableReasoningOnToolChoice"
494
527
  | "supportsToolChoice"
495
528
  | "supportsForcedToolChoice"
529
+ | "supportsNamedToolChoice"
496
530
  | "reasoningContentField"
497
531
  | "requiresReasoningContentForToolCalls"
498
532
  | "requiresReasoningContentForAllAssistantTurns"
499
533
  | "allowsSyntheticReasoningContentForToolCalls"
500
534
  | "replayReasoningContent"
535
+ | "qwenPreserveThinking"
501
536
  | "requiresThinkingAsText"
502
537
  | "requiresMistralToolIds"
503
538
  | "requiresToolResultName"
@@ -618,6 +653,18 @@ export type CompatOf<TApi extends Api> = TApi extends "openrouter"
618
653
  ? ResolvedDevinCompat
619
654
  : undefined;
620
655
 
656
+ /** Provider-native compaction endpoint configuration for one model. */
657
+ export interface RemoteCompactionConfig<TApi extends Api = Api> {
658
+ /** Enables provider-native compaction for providers not enabled by built-in policy. */
659
+ enabled?: boolean;
660
+ /** Adapter family used by the configured compaction endpoint. */
661
+ api?: TApi;
662
+ /** Absolute compact endpoint URL; when omitted, the adapter derives it from the model base URL. */
663
+ endpoint?: string;
664
+ /** Model id sent to the compaction endpoint when it differs from the active model id. */
665
+ model?: string;
666
+ }
667
+
621
668
  // Model interface for the unified model system
622
669
  export interface Model<TApi extends Api = Api> {
623
670
  id: string;
@@ -648,6 +695,8 @@ export interface Model<TApi extends Api = Api> {
648
695
  * reports that native tool calling is unsupported.
649
696
  */
650
697
  supportsTools?: boolean;
698
+ /** GitLab Duo Workflow root namespace selected during catalog discovery. */
699
+ gitlabDuoWorkflowRootNamespaceId?: string;
651
700
  cost: {
652
701
  input: number; // $/million tokens
653
702
  output: number; // $/million tokens
@@ -690,6 +739,10 @@ export interface Model<TApi extends Api = Api> {
690
739
  preferWebsockets?: boolean;
691
740
  /** Preferred model to switch to when context promotion is triggered (model id or provider/id). */
692
741
  contextPromotionTarget?: string;
742
+ /** Preferred model to use only for compaction (model id or provider/id); the active session model is unchanged. */
743
+ compactionModel?: string;
744
+ /** Provider-native compaction endpoint configuration. */
745
+ remoteCompaction?: RemoteCompactionConfig<TApi>;
693
746
  /** Provider-assigned priority value (lower = higher priority). */
694
747
  priority?: number;
695
748
  /** Canonical thinking capability metadata for this model. */
@@ -0,0 +1,42 @@
1
+ export const COREWEAVE_PROJECT_HEADER = "OpenAI-Project" as const;
2
+
3
+ export interface CoreWeaveProjectEnv {
4
+ [key: string]: string | undefined;
5
+ COREWEAVE_PROJECT?: string;
6
+ WANDB_INFERENCE_PROJECT?: string;
7
+ WANDB_ENTITY?: string;
8
+ WANDB_PROJECT?: string;
9
+ }
10
+
11
+ function cleanEnvValue(value: string | undefined): string | undefined {
12
+ const trimmed = value?.trim();
13
+ return trimmed ? trimmed : undefined;
14
+ }
15
+
16
+ export function resolveCoreWeaveProject(env: CoreWeaveProjectEnv): string | undefined {
17
+ const explicitProject = cleanEnvValue(env.COREWEAVE_PROJECT) ?? cleanEnvValue(env.WANDB_INFERENCE_PROJECT);
18
+ if (explicitProject) {
19
+ return explicitProject;
20
+ }
21
+
22
+ const wandbProject = cleanEnvValue(env.WANDB_PROJECT);
23
+ if (!wandbProject) {
24
+ return undefined;
25
+ }
26
+ if (wandbProject.includes("/")) {
27
+ return wandbProject;
28
+ }
29
+
30
+ const wandbEntity = cleanEnvValue(env.WANDB_ENTITY);
31
+ return wandbEntity ? `${wandbEntity}/${wandbProject}` : undefined;
32
+ }
33
+
34
+ export function coreWeaveProjectHeaders(env: CoreWeaveProjectEnv): Record<string, string> | undefined {
35
+ const project = resolveCoreWeaveProject(env);
36
+ return project ? { [COREWEAVE_PROJECT_HEADER]: project } : undefined;
37
+ }
38
+
39
+ export function hasCoreWeaveProjectHeader(headers: Record<string, string>): boolean {
40
+ const normalized = COREWEAVE_PROJECT_HEADER.toLowerCase();
41
+ return Object.keys(headers).some(header => header.toLowerCase() === normalized);
42
+ }