ghc-proxy 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -195,6 +195,8 @@ All fields are optional. The full schema:
195
195
  | `responsesApiAutoCompactInput` | `boolean` | `false` | Automatically trim Responses `input` to the latest `compaction` item |
196
196
  | `responsesApiAutoContextManagement` | `boolean` | `false` | Automatically inject Responses `context_management` for selected models |
197
197
  | `responsesApiContextManagementModels` | `string[]` | -- | Models eligible for auto-injected Responses `context_management` |
198
+ | `responsesApiParameterFilters` | `{ models, params }[]` | -- | Extra rules to strip request parameters on the Responses boundary (see [Responses Parameter Filters](#responses-parameter-filters)) |
199
+ | `responsesApiParameterFiltersReplaceDefault` | `boolean` | `false` | Disable the built-in reasoning-model default rule so only your `responsesApiParameterFilters` apply |
198
200
  | `responsesOfficialEmulator` | `boolean` | `false` | Enable local OpenAI-style Responses state emulation for `previous_response_id`, `conversation`, retrieve, input_items, delete, and input_tokens |
199
201
  | `responsesOfficialEmulatorTtlSeconds` | `number` | `14400` | In-memory TTL for locally emulated Responses state |
200
202
  | `modelReasoningEfforts` | `Record<string, string>` | -- | Per-model reasoning effort defaults for Anthropic-to-Responses translation |
@@ -381,6 +383,7 @@ This keeps the existing chat pipeline stable while allowing newer Copilot models
381
383
  - automatic Responses `context_management` injection is disabled by default and only applies when `responsesApiAutoContextManagement` is `true` and the model matches `responsesApiContextManagementModels`
382
384
  - automatic trimming of Responses `input` to the latest `compaction` item is disabled by default and only applies when `responsesApiAutoCompactInput` is `true`
383
385
  - reasoning defaults for Anthropic -> Responses translation can be tuned with `modelReasoningEfforts`
386
+ - request parameters that a model rejects (e.g. `temperature`/`top_p` on reasoning models) are stripped on the Responses boundary rather than leaked upstream as a `400`; see [Responses Parameter Filters](#responses-parameter-filters)
384
387
  - known unsupported builtin tools, such as `web_search`, fail explicitly with `400` instead of being silently removed
385
388
  - external image URLs on the Responses path fail explicitly with `400`; use `file_id` or data URL image input instead
386
389
  - official `input_file` and `item_reference` input items are modeled explicitly and validated before forwarding
@@ -399,6 +402,28 @@ Example opt-in configuration for these two Responses-specific policies:
399
402
 
400
403
  > See [Responses Upstream Notes](./docs/responses-upstream-notes.md) for detailed upstream compatibility observations from live testing.
401
404
 
405
+ ### Responses Parameter Filters
406
+
407
+ Some Copilot models reject request parameters that the OpenAI wire format allows. The clearest case: **reasoning models** (the `gpt-5` family, o-series, codex) reject sampling parameters and answer `POST /responses` with `400 Unsupported parameter: 'temperature' is not supported with this model.` Since the client cannot always be changed, the proxy strips the offending parameters on the Responses boundary instead of leaking the incompatibility outward.
408
+
409
+ This is expressed as a small rule engine that runs on both the native `/v1/responses` path and the `/v1/messages` → Responses translation path:
410
+
411
+ - **Built-in default rule:** any model that advertises `reasoning_effort` has `temperature` and `top_p` stripped. This covers the whole reasoning family (including future point releases like `gpt-5.4-mini`) with no configuration.
412
+ - **`responsesApiParameterFilters`:** add your own rules. Each rule is `{ "models": [glob, ...], "params": [name, ...] }`; every rule whose `models` glob matches the resolved model contributes its `params`. Rules are **added** to the default (the union of parameters is stripped). Model globs use the same `*` wildcard as `modelRewrites`.
413
+ - **`responsesApiParameterFiltersReplaceDefault`:** set to `true` to disable the built-in reasoning-model rule, so only your `responsesApiParameterFilters` apply — use this to fully **overwrite** the default behavior.
414
+
415
+ Stripped parameters are removed entirely (never sent as `null`), because upstream rejects the mere presence of the key.
416
+
417
+ ```json
418
+ {
419
+ "responsesApiParameterFilters": [
420
+ { "models": ["gpt-5*", "o1*"], "params": ["temperature", "top_p"] },
421
+ { "models": ["some-model"], "params": ["top_k"] }
422
+ ],
423
+ "responsesApiParameterFiltersReplaceDefault": false
424
+ }
425
+ ```
426
+
402
427
  ## Docker
403
428
 
404
429
  Pre-built images are available on GHCR:
package/dist/main.mjs CHANGED
@@ -5754,6 +5754,11 @@ const configFileSchema = object({
5754
5754
  responsesApiAutoCompactInput: boolean().optional(),
5755
5755
  responsesApiAutoContextManagement: boolean().optional(),
5756
5756
  responsesApiContextManagementModels: array(string()).optional(),
5757
+ responsesApiParameterFilters: array(object({
5758
+ models: array(string()).min(1),
5759
+ params: array(string()).min(1)
5760
+ })).optional(),
5761
+ responsesApiParameterFiltersReplaceDefault: boolean().optional(),
5757
5762
  responsesOfficialEmulator: boolean().optional(),
5758
5763
  responsesOfficialEmulatorTtlSeconds: number().int().positive().optional(),
5759
5764
  modelReasoningEfforts: record(string(), reasoningEffortSchema).optional(),
@@ -5871,6 +5876,12 @@ var ConfigStore = class {
5871
5876
  getReasoningEffort(model) {
5872
5877
  return getCachedConfig().modelReasoningEfforts?.[model] ?? "high";
5873
5878
  }
5879
+ getResponsesParameterFilters() {
5880
+ return getCachedConfig().responsesApiParameterFilters ?? [];
5881
+ }
5882
+ shouldReplaceDefaultParameterFilters() {
5883
+ return getCachedConfig().responsesApiParameterFiltersReplaceDefault ?? false;
5884
+ }
5874
5885
  getModelRewrites() {
5875
5886
  return getCachedConfig().modelRewrites ?? [];
5876
5887
  }
@@ -5933,6 +5944,9 @@ var ModelCache = class {
5933
5944
  supportsVision(model) {
5934
5945
  return model?.capabilities.supports.vision ?? false;
5935
5946
  }
5947
+ supportsReasoningEffort(model) {
5948
+ return (model?.capabilities.supports.reasoning_effort?.length ?? 0) > 0;
5949
+ }
5936
5950
  supportsOutputConfig(model) {
5937
5951
  if (!model) return true;
5938
5952
  return !MODELS_REJECTING_OUTPUT_CONFIG.has(model.id);
@@ -7350,7 +7364,7 @@ const checkUsage = defineCommand({
7350
7364
  });
7351
7365
  //#endregion
7352
7366
  //#region src/util/version.ts
7353
- const VERSION = "0.7.0";
7367
+ const VERSION = "0.7.2";
7354
7368
  //#endregion
7355
7369
  //#region src/debug.ts
7356
7370
  function getRuntimeInfo() {
@@ -48603,10 +48617,15 @@ const anthropicMcpToolUseBlockSchema = object({
48603
48617
  input: jsonObjectSchema,
48604
48618
  server_name: string().min(1)
48605
48619
  }).loose();
48620
+ const anthropicDocumentBlockSchema = object({
48621
+ type: literal("document"),
48622
+ source: jsonObjectSchema
48623
+ }).loose();
48606
48624
  const anthropicToolResultContentBlockSchema = union([
48607
48625
  anthropicTextBlockSchema,
48608
48626
  anthropicImageBlockSchema,
48609
- anthropicSearchResultBlockSchema
48627
+ anthropicSearchResultBlockSchema,
48628
+ anthropicDocumentBlockSchema
48610
48629
  ]);
48611
48630
  const anthropicToolResultBlockSchema = object({
48612
48631
  type: literal("tool_result"),
@@ -48634,10 +48653,6 @@ const anthropicServerToolResultBlockSchema = object({
48634
48653
  content: unknown(),
48635
48654
  is_error: boolean().optional()
48636
48655
  }).loose();
48637
- const anthropicDocumentBlockSchema = object({
48638
- type: literal("document"),
48639
- source: jsonObjectSchema
48640
- }).loose();
48641
48656
  const anthropicMessageSchema = union([
48642
48657
  object({
48643
48658
  role: literal("user"),
@@ -49624,6 +49639,31 @@ function normalizeOutputConfigEffort(effort, model) {
49624
49639
  function hasOutputConfigFormat(payload) {
49625
49640
  return payload?.output_config?.format != null;
49626
49641
  }
49642
+ function budgetTokensToEffort(budget) {
49643
+ if (budget >= 24e3) return "high";
49644
+ if (budget >= 8e3) return "medium";
49645
+ return "low";
49646
+ }
49647
+ /**
49648
+ * Models whose upstream `/v1/messages` endpoint only accepts the adaptive
49649
+ * thinking API reject the classic `thinking.type: "enabled"` shape with a 400.
49650
+ * Convert `enabled` → `adaptive` (+ derive `output_config.effort` from the
49651
+ * requested `budget_tokens`) for those models before forwarding. Models that
49652
+ * do not advertise `adaptive_thinking` keep the classic `enabled` shape.
49653
+ *
49654
+ * Must run before `sanitizeOutputConfig` so the derived effort is normalized
49655
+ * against the model's advertised efforts.
49656
+ */
49657
+ function convertEnabledThinkingToAdaptive(payload, model) {
49658
+ if (payload.thinking?.type !== "enabled") return;
49659
+ if (!modelCache.supportsAdaptiveThinking(model)) return;
49660
+ const budget = payload.thinking.budget_tokens;
49661
+ payload.thinking = { type: "adaptive" };
49662
+ if (payload.output_config?.effort == null && modelCache.supportsOutputConfig(model)) payload.output_config = {
49663
+ ...payload.output_config,
49664
+ effort: budgetTokensToEffort(budget)
49665
+ };
49666
+ }
49627
49667
  function sanitizeOutputConfig(payload, model) {
49628
49668
  if (!payload.output_config) return;
49629
49669
  if (!modelCache.supportsOutputConfig(model)) {
@@ -49654,6 +49694,44 @@ const messagesModelChain = composeModelTransforms(rewriteStep, modelPolicyStep);
49654
49694
  const chatCompletionsModelChain = composeModelTransforms(rewriteStep);
49655
49695
  const responsesModelChain = composeModelTransforms(rewriteStep);
49656
49696
  //#endregion
49697
+ //#region src/translator/anthropic/document.ts
49698
+ /** Trimmed text of a `content`-source document part, or undefined when it carries none. */
49699
+ function documentPartText(part) {
49700
+ if (part && typeof part === "object" && typeof part.text === "string") return part.text.trim() || void 0;
49701
+ }
49702
+ /** A short reference identifying a non-text document source, or '' when none is available. */
49703
+ function documentSourceRef(source) {
49704
+ if (typeof source.url === "string") return `: ${source.url}`;
49705
+ if (typeof source.file_id === "string") return `: ${source.file_id}`;
49706
+ if (typeof source.media_type === "string") return `: ${source.media_type}`;
49707
+ return "";
49708
+ }
49709
+ /**
49710
+ * Flatten an Anthropic `document` content block to plain text for translation
49711
+ * paths that cannot carry an attachment (Chat Completions, and the native
49712
+ * mixed-search-result flatten path). Text and content sources inline their
49713
+ * text; file/url/base64 sources have no text to inline, so they degrade to a
49714
+ * reference-labelled placeholder (mirroring `[image omitted: <media_type>]`)
49715
+ * rather than a content-free token. Anthropic allows `document` blocks inside
49716
+ * `tool_result.content` alongside `text`, `image`, and `search_result`.
49717
+ */
49718
+ function formatDocumentBlock(block) {
49719
+ const source = block.source;
49720
+ if (source.type === "text" && typeof source.data === "string") {
49721
+ const text = source.data.trim();
49722
+ return text ? `[document]\n${text}` : "[document]";
49723
+ }
49724
+ if (source.type === "content" && Array.isArray(source.content)) {
49725
+ const parts = [];
49726
+ for (const part of source.content) {
49727
+ const text = documentPartText(part);
49728
+ if (text) parts.push(text);
49729
+ }
49730
+ return parts.length ? `[document]\n${parts.join("\n")}` : "[document]";
49731
+ }
49732
+ return `[document${documentSourceRef(source)}]`;
49733
+ }
49734
+ //#endregion
49657
49735
  //#region src/translator/anthropic/search-result.ts
49658
49736
  function formatSearchResultBlock(block) {
49659
49737
  const content = block.content.map((part) => part.text.trim()).filter(Boolean).join("\n");
@@ -49697,6 +49775,7 @@ function normalizeToolResultContentValue(content) {
49697
49775
  case "text": return textBlock(contentBlock.text);
49698
49776
  case "image": return imageBlock(contentBlock.source.media_type, contentBlock.source.data);
49699
49777
  case "search_result": return textBlock(formatSearchResultBlock(contentBlock));
49778
+ case "document": return textBlock(formatDocumentBlock(contentBlock));
49700
49779
  default: return assertNever(contentBlock);
49701
49780
  }
49702
49781
  });
@@ -50865,6 +50944,61 @@ function containsVisionContent(value) {
50865
50944
  return false;
50866
50945
  }
50867
50946
  //#endregion
50947
+ //#region src/transform/parameter-filter.ts
50948
+ /**
50949
+ * Parameters the default rule strips for reasoning models on the Responses
50950
+ * boundary. Reasoning models (gpt-5 family, o-series, codex) reject sampling
50951
+ * parameters upstream with a 400 "Unsupported parameter" error, so the proxy
50952
+ * drops them instead of leaking the incompatibility to the client.
50953
+ */
50954
+ const DEFAULT_REASONING_UNSUPPORTED_PARAMS = ["temperature", "top_p"];
50955
+ /**
50956
+ * A reasoning model is any model that advertises one or more
50957
+ * `reasoning_effort` levels. This dynamically covers the full reasoning
50958
+ * family (mini, codex, future point releases) without a hardcoded ID list.
50959
+ */
50960
+ function isReasoningModel(model) {
50961
+ return modelCache.supportsReasoningEffort(model);
50962
+ }
50963
+ /**
50964
+ * Resolve the set of request parameters to strip for a given model on the
50965
+ * Responses boundary.
50966
+ *
50967
+ * Rule composition:
50968
+ * 1. Built-in default: reasoning models strip {@link DEFAULT_REASONING_UNSUPPORTED_PARAMS}.
50969
+ * Disabled entirely when `responsesApiParameterFiltersReplaceDefault` is true.
50970
+ * 2. User rules (`responsesApiParameterFilters`): every rule whose `models`
50971
+ * glob matches the resolved model id contributes its `params`.
50972
+ *
50973
+ * The result is the union of all matching rules, so user rules ADD to the
50974
+ * default. Setting `responsesApiParameterFiltersReplaceDefault: true` disables
50975
+ * the default so user rules fully OVERWRITE it.
50976
+ */
50977
+ function resolveStrippedResponsesParams(model) {
50978
+ const params = /* @__PURE__ */ new Set();
50979
+ if (!configStore.shouldReplaceDefaultParameterFilters() && isReasoningModel(model)) for (const param of DEFAULT_REASONING_UNSUPPORTED_PARAMS) params.add(param);
50980
+ const modelId = model?.id;
50981
+ if (modelId) {
50982
+ for (const rule of configStore.getResponsesParameterFilters()) if (rule.models.some((pattern) => matchesGlob(pattern, modelId))) for (const param of rule.params) params.add(param);
50983
+ }
50984
+ return params;
50985
+ }
50986
+ /**
50987
+ * Strip unsupported parameters from a Responses payload before dispatch.
50988
+ * Keys are deleted entirely (never set to null) because upstream rejects the
50989
+ * mere presence of the key, not just non-null values.
50990
+ */
50991
+ function applyResponsesParameterFilters(payload, model) {
50992
+ const strip = resolveStrippedResponsesParams(model);
50993
+ if (strip.size === 0) return;
50994
+ const removed = [];
50995
+ for (const key of strip) if (key in payload) {
50996
+ delete payload[key];
50997
+ removed.push(key);
50998
+ }
50999
+ if (removed.length > 0) consola.debug(`Stripped unsupported responses params for model ${model?.id}: ${removed.join(", ")}`);
51000
+ }
51001
+ //#endregion
50868
51002
  //#region src/translator/responses/function-schema.ts
50869
51003
  function isRecord$2(value) {
50870
51004
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -51057,6 +51191,12 @@ function convertToolResultContent(content) {
51057
51191
  case "search_result":
51058
51192
  result.push(createTextContent(formatSearchResultBlock(block)));
51059
51193
  break;
51194
+ case "document": {
51195
+ const source = block.source;
51196
+ if (source.type === "file" || source.type === "url" || source.type === "base64") result.push(createDocumentContent(block));
51197
+ else result.push(createTextContent(formatDocumentBlock(block)));
51198
+ break;
51199
+ }
51060
51200
  default: break;
51061
51201
  }
51062
51202
  return result;
@@ -51381,6 +51521,7 @@ function stringifyToolResultContent(content) {
51381
51521
  case "text": return block.text;
51382
51522
  case "image": return `[image omitted: ${block.source.media_type}]`;
51383
51523
  case "search_result": return formatSearchResultBlock(block);
51524
+ case "document": return formatDocumentBlock(block);
51384
51525
  }
51385
51526
  return "";
51386
51527
  }).filter(Boolean).join("\n\n");
@@ -52004,6 +52145,7 @@ const nativeMessagesEntry = {
52004
52145
  name: "native-messages",
52005
52146
  canHandle: (model, ctx) => modelCache.supportsEndpoint(model, "/v1/messages") && !hasOutputConfigFormat(ctx?.anthropicPayload),
52006
52147
  async execute(ctx) {
52148
+ convertEnabledThinkingToAdaptive(ctx.anthropicPayload, ctx.selectedModel);
52007
52149
  filterThinkingBlocksForNativeMessages(ctx.anthropicPayload);
52008
52150
  sanitizeOutputConfig(ctx.anthropicPayload, ctx.selectedModel);
52009
52151
  sanitizeCacheControl(ctx.anthropicPayload);
@@ -52020,6 +52162,7 @@ const responsesApiEntry = {
52020
52162
  const responsesPayload = withTranslationErrors(() => translateAnthropicToResponsesPayload(ctx.anthropicPayload, { reasoningEffortResolver: (model) => configStore.getReasoningEffort(model) }));
52021
52163
  applyContextManagement(responsesPayload, ctx.selectedModel?.capabilities.limits.max_prompt_tokens);
52022
52164
  compactInputByLatestCompaction(responsesPayload);
52165
+ applyResponsesParameterFilters(responsesPayload, ctx.selectedModel);
52023
52166
  const { vision, initiator } = getResponsesRequestOptions(responsesPayload);
52024
52167
  return await runStrategy(createMessagesViaResponsesStrategy(ctx.copilotClient, responsesPayload, {
52025
52168
  vision,
@@ -52517,6 +52660,7 @@ async function handleResponsesCore({ body, signal, headers }) {
52517
52660
  if (!selectedModel) throwInvalidRequestError("The selected model could not be resolved.", "model");
52518
52661
  if (!modelCache.supportsEndpoint(selectedModel, "/responses")) throwInvalidRequestError("The selected model does not support the responses endpoint.", "model");
52519
52662
  applyContextManagement(payload, selectedModel.capabilities.limits.max_prompt_tokens);
52663
+ applyResponsesParameterFilters(payload, selectedModel);
52520
52664
  },
52521
52665
  buildStrategyContext({ payload, meta, copilotClient, upstreamSignal }) {
52522
52666
  const { vision, initiator } = getResponsesRequestOptions(payload);