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 +25 -0
- package/dist/main.mjs +150 -6
- package/dist/main.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
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);
|