What happened?
Summary
When Atomic replays GitHub Copilot tool calls to an openai-responses model, and the replayed assistant messages carry a different model id than the outgoing request, its outbound payload sanitizer fills in an omitted function_call.id with a synthesized fc_* identifier that is exactly 64 characters long. The server rejects that identifier with 400 invalid_request_body.
The synthesized value is a pure function of the tool call it derives from, so every resend rebuilds the identical rejected value. The session cannot make progress until its context is edited by hand.
The server's error text blames the character class of the identifier. That text is misleading: the rejected value contains only letters, numbers, and underscores. Length is the most likely remaining explanation, but it is not confirmed against the live API — see the confidence breakdown in the technical analysis.
Environment
| Item |
Value |
Atomic (@bastani/atomic) |
0.9.12 (global npm install) |
Bundled @earendil-works/pi-ai |
0.83.0 |
Bundled openai Node SDK |
6.26.0 |
| Node.js |
v24.15.0 |
| OS |
Linux, kernel 6.8 |
| Provider |
github-copilot |
| Model |
gpt-5.6-sol-short |
| API surface |
openai-responses |
Actual behavior
The request fails with HTTP 400 before any generation starts. Atomic records the turn as an assistant message with stopReason: "error", zero content blocks, and zero billed tokens. The turn produces no output and the user sees only the raw provider error.
The rejected id is a value Atomic itself created. The conversion layer beneath it had emitted the item with no id at all.
Error excerpt (sanitized)
All identifiers below are fake placeholders.
OpenAI API error (400): {"message":"Invalid 'input[N].id': 'fc_call_AAAAAAAAAAAAAAAAAAAAAAAA_PLACEHOLDER_OP_ZZZZZZZZZZZZZZZZ'. Expected an ID that contains letters, numbers, underscores, or dashes, but this value contained additional characters.","code":"invalid_request_body"}
The rejected value is 64 characters, and so is the placeholder above. Every character is in [A-Za-z0-9_]. Nothing in it falls outside the set the message names.
Frequency and retry behavior
Observed four times in a single session, across two distinct synthesized identifiers.
The second identifier was rejected three separate times over roughly 83 minutes. Each rejection was byte-identical and named the same input index. None of the three was an automatic retry: the first arose in the normal agent loop after a tool result, and the two later ones followed user-initiated turns. The harness does not auto-retry this failure — see below.
This is deterministic, not flaky. The synthesized identifier is derived from the tool call by a pure function — sanitize, truncate, hash — so any resend regenerates exactly the value the server already refused. There is no self-healing path, and no automatic one either: pi-ai's isRetryableProviderError retries only 408, 409, 429, and 5xx, the OpenAI client for this API is constructed with maxRetries: 0, and Atomic's own retry classifier covers overload, rate-limit, quota, 429, 5xx, and network/stream failures without mentioning HTTP 400 or invalid_request_body. The session contains no automatic-retry records. Recovery requires removing or rewriting the offending item in the conversation context.
Impact
- A Copilot
openai-responses session hard-stops once an affected tool call enters the replayed context. Resending does not clear it, and nothing retries it automatically.
- The failure is silent about its real cause. The surfaced message points at illegal characters that are not present, which sends users looking in the wrong place.
- The 400 arrives before generation, so no work is produced and no tokens are billed.
- Any long-running Copilot session that uses tools is exposed. Tool use is the normal mode of operation for a coding agent.
Technical analysis
Three layers combine, and a precondition gates the first. Steps A and B are in the bundled pi-ai package; step C is Atomic's own code and is where the rejected value is produced.
Precondition — the normalizer only runs across a model-identity mismatch
In node_modules/@earendil-works/pi-ai/dist/api/transform-messages.js:
const isSameModel = assistantMsg.provider === model.provider &&
assistantMsg.api === model.api &&
assistantMsg.model === model.id;
...
if (!isSameModel && normalizeToolCallId) {
const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
...
}
Tool-call identifier normalization is skipped entirely when the replayed assistant message matches the outgoing request on all three of provider, api, and model id. Step A therefore only happens on replay across a mismatch. When the ids match, the raw composite identifier survives, split("|") in step B works as designed, call_id is the short 29-character call_ segment, and step C's direct path yields a valid 32-character fc_call_<24 alnum>.
Proven. Replaying the shipped conversion over the same 126 identifiers both ways gives 64-character ids and 2/2 byte-exact reconstructions of the rejected values with a mismatch, and 32-character ids and 0/2 reconstructions without one.
A mismatch is easy to create without meaning to. In the observed session it came from a provider extension that registers one model id while streaming from a differently-named source model and relabelling the persisted messages back to the registered id, so every replayed assistant message disagreed with the outgoing request. A plain mid-session model switch or a session resumed under a different model produces the same condition.
Step A — the composite identifier is flattened, not split
In node_modules/@earendil-works/pi-ai/dist/api/openai-responses-shared.js:
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]); // openai-responses.js
const normalizeIdPart = (part) => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
return normalized.replace(/_+$/, "");
};
const normalizeToolCallId = (id, _targetModel, source) => {
if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id); // github-copilot takes this branch
if (!id.includes("|")) return normalizeIdPart(id);
const [callId, itemId] = id.split("|");
...
};
github-copilot is not a member of OPENAI_TOOL_CALL_PROVIDERS, so control never reaches the split. The entire composite string is handed to normalizeIdPart, which rewrites |, +, and / to _, truncates to 64 characters, and strips trailing underscores.
The | separator becomes an ordinary _. The boundary between the call segment and the item segment is destroyed.
Proven. In the observed session all 126 distinct Copilot tool-call identifiers were exactly 454 characters, all matched the call_<24 alphanumeric> + | + <424-char blob> shape, and all 126 contained |, +, and /.
Step B — the item id ends up absent
Later in the same file, when the function_call item is emitted:
const [callId, itemIdRaw] = toolCall.id.split("|");
let itemId = itemIdRaw;
// For different-model messages, set id to undefined to avoid pairing validation.
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
// By omitting the id, we avoid triggering that validation (like cross-provider does).
if ((isDifferentModel && itemId?.startsWith("fc_")) ||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))) {
itemId = undefined;
}
output.push({ type: "function_call", id: itemId, call_id: callId, ... });
Step A already removed the |, so split("|") returns a single element. callId becomes the whole 64-character normalized string and itemIdRaw is undefined.
The guard is then entered, because !itemId?.startsWith("fc_") is true for an undefined itemId, and it assigns undefined to a variable that is already undefined. On this path the absence is a mechanical consequence of step A rather than a decision. The quoted comment describes the different-model and custom-tool cases the guard was written for, not this one.
The distinction matters for how step C should behave, and it cuts the same way either way: in the cases the guard was written for, an absent id is deliberate and must be left alone; on this path it is incidental, but re-deriving it from a call_id that step A already mangled cannot recover the real item id. Neither reading justifies synthesizing a replacement.
Proven by reading the shipped code and by the reconstruction in step C, which only matches if call_id carries the full 64-character string.
Step C — Atomic fills the id back in, and makes it 64 characters
In dist/core/openai-responses-payload-sanitizer.js, applied from dist/core/sdk.js inside the onPayload hook — the last transform before the request leaves the process:
const RESPONSES_FUNCTION_CALL_ID = /^fc_[A-Za-z0-9_-]{1,61}$/; // permits total length up to 64
const MAX_RESPONSES_FUNCTION_CALL_ID_LENGTH = 64;
export function responsesFunctionCallIdForCallId(callId) {
if (typeof callId !== "string" || callId.length === 0) return undefined;
const direct = `fc_${callId}`;
if (isValidResponsesFunctionCallId(direct)) return direct;
const sanitized = sanitizedCallIdFragment(callId);
const hash = sha256Base64Url(callId).slice(0, 16);
const suffixBudget = 64 - 3; // 61
const suffix = sanitized.length > 0 ? `${sanitized.slice(0, suffixBudget - hash.length - 1)}_${hash}` : hash;
return `fc_${suffix}`; // 3 + 44 + 1 + 16 = exactly 64
}
function sanitizeResponsesFunctionCall(item) {
if (item.type !== "function_call") return false;
if (isValidResponsesFunctionCallId(item.id)) return false; // item.id === undefined -> false -> synthesize
const synthesized = responsesFunctionCallIdForCallId(item.call_id);
if (synthesized) { item.id = synthesized; } else { delete item.id; }
return true;
}
item.id is undefined. isValidResponsesFunctionCallId(undefined) returns false, so the guard treats absent exactly like malformed and proceeds to synthesize a replacement from call_id.
The direct path is tried first: "fc_" plus a 64-character call_id is 67 characters, which fails RESPONSES_FUNCTION_CALL_ID on length. So the hashed path runs, and its arithmetic lands on the boundary:
"fc_" 3
sanitized.slice(0, 61 - 16 - 1) 44
"_" 1
sha256 base64url prefix 16
---
64
Every synthesized identifier from this path is exactly 64 characters. Atomic's own regex, ^fc_[A-Za-z0-9_-]{1,61}$, permits a total length of 64 and therefore blesses the value. The server refuses it.
Proven byte-for-byte. Re-implementing normalizeIdPart and responsesFunctionCallIdForCallId and replaying them over the persisted raw Copilot tool-call identifiers reproduced both rejected values exactly. Scanning 657,440 distinct identifiers yielded exactly two matches — the two identifiers named in the two distinct 400 responses — and no false positives.
Net effect
Atomic fills in a function_call.id that the layer beneath it left absent, and the value it fills in is one the server will not accept. The sanitizer intended to repair malformed identifiers instead manufactures an invalid one where none was needed.
What is proven, inferred, and unknown
Proven locally:
- Both rejected identifiers are exactly 64 characters, 64 bytes, pure ASCII, drawn only from
[A-Za-z0-9_].
- Copilot tool-call identifiers are composite
callId|blob values containing |, +, and /.
- Tool-call identifier normalization runs only when the replayed assistant message differs from the outgoing request on
provider, api, or model id. With a match the emitted id is 32 characters and the failure cannot occur.
github-copilot is absent from OPENAI_TOOL_CALL_PROVIDERS, so once normalization does run the composite value is flattened rather than split.
- On this path
pi-ai emits the item with id absent, and the guard's itemId = undefined assignment is a no-op because step A already left it undefined.
- Atomic's sanitizer synthesizes an identifier when
id is absent, and that identifier is always exactly 64 characters.
- Both rejected identifiers reconstruct byte-for-byte from persisted tool-call identifiers, and only along the mismatch path.
- The failure is deterministic: three identical rejections of the same value across roughly 83 minutes, none of them an automatic retry.
- No layer auto-retries this error: pi-ai retries only 408/409/429/5xx, the client is built with
maxRetries: 0, and Atomic's classifier does not cover HTTP 400 or invalid_request_body.
Inferred, not locally testable:
- The server's accepted maximum length for a
function_call.id is strictly below 64. The value satisfies the character rule the error message names, so length is the most likely remaining discriminator. Confidence is medium. The live API was not probed, so the exact bound is unknown — it could be 63, or the grammar could impose a constraint not captured by either length or character class. Maintainers with API access should confirm the real bound before hard-coding a new limit.
- The upstream error message is inaccurate for this input. It reports a character-class violation that does not exist. Whether it is a generic catch-all message or a distinct rule is not determinable from here.
Unknown, questions for maintainers:
- Is omitting
github-copilot from OPENAI_TOOL_CALL_PROVIDERS deliberate, or an oversight? The set has been kept to first-party OpenAI-shaped providers, which may be intentional.
- What is the server's true grammar and length bound for
function_call.id?
- Do other providers outside
OPENAI_TOOL_CALL_PROVIDERS that return composite identifiers reach the same path? Any provider whose identifiers contain | and exceed 64 characters after normalization appears equally exposed, again only across a model-identity mismatch.
- Should a provider extension that aliases a model id be expected to make every replayed message look cross-model? That is what turns this from a rare mid-session-switch edge case into a condition that holds for an entire session.
Likely fix direction
These are ranked. Maintainers should choose; the first alone should resolve the reported failure.
Primary — stop treating an absent id as a malformed one.
In sanitizeResponsesFunctionCall, separate the two cases. When item.id is undefined or the key is absent, leave it absent. Where the layer below omits it deliberately, overriding that decision is what produces the rejected value; where the absence is incidental, the call_id it would be re-derived from has already been mangled and cannot yield the real item id. Restrict synthesis to an id that is actually present and actually malformed:
if (!("id" in item) || item.id === undefined) return false; // absent by design or by consequence; leave it absent
if (isValidResponsesFunctionCallId(item.id)) return false;
Secondary — if synthesis stays, do not target the boundary.
Budget the synthesized length below the server's real bound rather than aiming at exactly 64, and tighten RESPONSES_FUNCTION_CALL_ID to the same bound. Today the validator accepts a 64-character value the server rejects, so Atomic's own check cannot catch this class of failure. Validator and generator should agree, and both should sit under the server limit rather than on it.
Tertiary, upstream — consider splitting Copilot composite identifiers.
Evaluate whether github-copilot belongs in OPENAI_TOOL_CALL_PROVIDERS. With it included, callId|itemId would be split and normalized per part instead of flattened into one truncated blob, and the | boundary would survive. This is a broader behavioral change than the primary fix and should not be made solely to address this report.
Regression test.
Replay a function_call item whose id is absent and whose call_id is a 64-character sanitized Copilot-style value. Assert that the sanitizer emits no id, and in particular no fc_* value at or above the server's length bound. The test should fail against 0.9.12.
Acceptance criteria
Privacy note
This report was produced from a local session transcript and a local package installation.
Every identifier shown here is a fake placeholder written for this document. No real session identifiers, message identifiers, tool-call identifiers, or fragments of them appear. No prompt content, task content, repository name, file paths from the reporting machine, usernames, or credentials are included. Package paths are given relative to the installed package root.
Counts, lengths, timestamps, and version numbers are reported as measured, because they carry the technical argument and identify nothing.
Steps to reproduce
Steps to reproduce / minimal conditions
No special prompt content is needed. The conditions are structural, but note condition 3 — without it the failure does not occur:
- Select an
openai-responses model served by the github-copilot provider.
- Let the model issue tool calls. GitHub Copilot returns composite tool-call identifiers of the form
call_AAAAAAAAAAAAAAAAAAAAAAAA|PLACEHOLDER_OPAQUE_BASE64_BLOB... — a short call_ segment, a | separator, and a long opaque base64 segment containing + and /.
- Arrange for the replayed assistant messages to carry a different model identity than the outgoing request. Any of these will do: a mid-session model switch, a session resumed under a different model, or a provider extension that registers an aliased model id and streams from a differently-named source model. The relevant comparison is
provider, api, and model id together — see step A below.
- Continue the conversation so those tool calls are replayed as prior
input items in a later request.
- The request carrying the replayed
function_call item fails with the 400 above.
The trigger is a Copilot tool-call identifier long enough that its normalized form reaches the 64-character truncation limit, replayed across a model-identity mismatch. In the observed session every identifier was long enough, and the mismatch held for every replayed message.
Without the mismatch in condition 3 the normalizer never runs, the emitted id is a 32-character fc_call_<24 alnum>, and no 400 is possible. This was checked by replaying the shipped conversion over the same 126 identifiers both ways: with the mismatch every emitted id is 64 characters and both rejected identifiers reconstruct exactly; without it every emitted id is 32 characters and neither reconstructs.
Expected behavior
Expected behavior
The request should succeed. Atomic should not fill in a function_call.id that the conversion layer beneath it left absent, and it should never emit an identifier its own validator accepts but the server rejects.
Version
0.9.12
What happened?
Summary
When Atomic replays GitHub Copilot tool calls to an
openai-responsesmodel, and the replayed assistant messages carry a different model id than the outgoing request, its outbound payload sanitizer fills in an omittedfunction_call.idwith a synthesizedfc_*identifier that is exactly 64 characters long. The server rejects that identifier with400 invalid_request_body.The synthesized value is a pure function of the tool call it derives from, so every resend rebuilds the identical rejected value. The session cannot make progress until its context is edited by hand.
The server's error text blames the character class of the identifier. That text is misleading: the rejected value contains only letters, numbers, and underscores. Length is the most likely remaining explanation, but it is not confirmed against the live API — see the confidence breakdown in the technical analysis.
Environment
@bastani/atomic)@earendil-works/pi-aiopenaiNode SDKgithub-copilotgpt-5.6-sol-shortopenai-responsesActual behavior
The request fails with HTTP 400 before any generation starts. Atomic records the turn as an assistant message with
stopReason: "error", zero content blocks, and zero billed tokens. The turn produces no output and the user sees only the raw provider error.The rejected
idis a value Atomic itself created. The conversion layer beneath it had emitted the item with noidat all.Error excerpt (sanitized)
All identifiers below are fake placeholders.
The rejected value is 64 characters, and so is the placeholder above. Every character is in
[A-Za-z0-9_]. Nothing in it falls outside the set the message names.Frequency and retry behavior
Observed four times in a single session, across two distinct synthesized identifiers.
The second identifier was rejected three separate times over roughly 83 minutes. Each rejection was byte-identical and named the same
inputindex. None of the three was an automatic retry: the first arose in the normal agent loop after a tool result, and the two later ones followed user-initiated turns. The harness does not auto-retry this failure — see below.This is deterministic, not flaky. The synthesized identifier is derived from the tool call by a pure function — sanitize, truncate, hash — so any resend regenerates exactly the value the server already refused. There is no self-healing path, and no automatic one either: pi-ai's
isRetryableProviderErrorretries only 408, 409, 429, and 5xx, the OpenAI client for this API is constructed withmaxRetries: 0, and Atomic's own retry classifier covers overload, rate-limit, quota, 429, 5xx, and network/stream failures without mentioning HTTP 400 orinvalid_request_body. The session contains no automatic-retry records. Recovery requires removing or rewriting the offending item in the conversation context.Impact
openai-responsessession hard-stops once an affected tool call enters the replayed context. Resending does not clear it, and nothing retries it automatically.Technical analysis
Three layers combine, and a precondition gates the first. Steps A and B are in the bundled
pi-aipackage; step C is Atomic's own code and is where the rejected value is produced.Precondition — the normalizer only runs across a model-identity mismatch
In
node_modules/@earendil-works/pi-ai/dist/api/transform-messages.js:Tool-call identifier normalization is skipped entirely when the replayed assistant message matches the outgoing request on all three of
provider,api, andmodelid. Step A therefore only happens on replay across a mismatch. When the ids match, the raw composite identifier survives,split("|")in step B works as designed,call_idis the short 29-charactercall_segment, and step C's direct path yields a valid 32-characterfc_call_<24 alnum>.Proven. Replaying the shipped conversion over the same 126 identifiers both ways gives 64-character ids and 2/2 byte-exact reconstructions of the rejected values with a mismatch, and 32-character ids and 0/2 reconstructions without one.
A mismatch is easy to create without meaning to. In the observed session it came from a provider extension that registers one model id while streaming from a differently-named source model and relabelling the persisted messages back to the registered id, so every replayed assistant message disagreed with the outgoing request. A plain mid-session model switch or a session resumed under a different model produces the same condition.
Step A — the composite identifier is flattened, not split
In
node_modules/@earendil-works/pi-ai/dist/api/openai-responses-shared.js:github-copilotis not a member ofOPENAI_TOOL_CALL_PROVIDERS, so control never reaches the split. The entire composite string is handed tonormalizeIdPart, which rewrites|,+, and/to_, truncates to 64 characters, and strips trailing underscores.The
|separator becomes an ordinary_. The boundary between the call segment and the item segment is destroyed.Proven. In the observed session all 126 distinct Copilot tool-call identifiers were exactly 454 characters, all matched the
call_<24 alphanumeric>+|+<424-char blob>shape, and all 126 contained|,+, and/.Step B — the item id ends up absent
Later in the same file, when the
function_callitem is emitted:Step A already removed the
|, sosplit("|")returns a single element.callIdbecomes the whole 64-character normalized string anditemIdRawisundefined.The guard is then entered, because
!itemId?.startsWith("fc_")is true for an undefineditemId, and it assignsundefinedto a variable that is alreadyundefined. On this path the absence is a mechanical consequence of step A rather than a decision. The quoted comment describes the different-model and custom-tool cases the guard was written for, not this one.The distinction matters for how step C should behave, and it cuts the same way either way: in the cases the guard was written for, an absent
idis deliberate and must be left alone; on this path it is incidental, but re-deriving it from acall_idthat step A already mangled cannot recover the real item id. Neither reading justifies synthesizing a replacement.Proven by reading the shipped code and by the reconstruction in step C, which only matches if
call_idcarries the full 64-character string.Step C — Atomic fills the id back in, and makes it 64 characters
In
dist/core/openai-responses-payload-sanitizer.js, applied fromdist/core/sdk.jsinside theonPayloadhook — the last transform before the request leaves the process:item.idisundefined.isValidResponsesFunctionCallId(undefined)returnsfalse, so the guard treats absent exactly like malformed and proceeds to synthesize a replacement fromcall_id.The direct path is tried first:
"fc_"plus a 64-charactercall_idis 67 characters, which failsRESPONSES_FUNCTION_CALL_IDon length. So the hashed path runs, and its arithmetic lands on the boundary:Every synthesized identifier from this path is exactly 64 characters. Atomic's own regex,
^fc_[A-Za-z0-9_-]{1,61}$, permits a total length of 64 and therefore blesses the value. The server refuses it.Proven byte-for-byte. Re-implementing
normalizeIdPartandresponsesFunctionCallIdForCallIdand replaying them over the persisted raw Copilot tool-call identifiers reproduced both rejected values exactly. Scanning 657,440 distinct identifiers yielded exactly two matches — the two identifiers named in the two distinct 400 responses — and no false positives.Net effect
Atomic fills in a
function_call.idthat the layer beneath it left absent, and the value it fills in is one the server will not accept. The sanitizer intended to repair malformed identifiers instead manufactures an invalid one where none was needed.What is proven, inferred, and unknown
Proven locally:
[A-Za-z0-9_].callId|blobvalues containing|,+, and/.provider,api, ormodelid. With a match the emitted id is 32 characters and the failure cannot occur.github-copilotis absent fromOPENAI_TOOL_CALL_PROVIDERS, so once normalization does run the composite value is flattened rather than split.pi-aiemits the item withidabsent, and the guard'sitemId = undefinedassignment is a no-op because step A already left it undefined.idis absent, and that identifier is always exactly 64 characters.maxRetries: 0, and Atomic's classifier does not cover HTTP 400 orinvalid_request_body.Inferred, not locally testable:
function_call.idis strictly below 64. The value satisfies the character rule the error message names, so length is the most likely remaining discriminator. Confidence is medium. The live API was not probed, so the exact bound is unknown — it could be 63, or the grammar could impose a constraint not captured by either length or character class. Maintainers with API access should confirm the real bound before hard-coding a new limit.Unknown, questions for maintainers:
github-copilotfromOPENAI_TOOL_CALL_PROVIDERSdeliberate, or an oversight? The set has been kept to first-party OpenAI-shaped providers, which may be intentional.function_call.id?OPENAI_TOOL_CALL_PROVIDERSthat return composite identifiers reach the same path? Any provider whose identifiers contain|and exceed 64 characters after normalization appears equally exposed, again only across a model-identity mismatch.Likely fix direction
These are ranked. Maintainers should choose; the first alone should resolve the reported failure.
Primary — stop treating an absent
idas a malformed one.In
sanitizeResponsesFunctionCall, separate the two cases. Whenitem.idisundefinedor the key is absent, leave it absent. Where the layer below omits it deliberately, overriding that decision is what produces the rejected value; where the absence is incidental, thecall_idit would be re-derived from has already been mangled and cannot yield the real item id. Restrict synthesis to anidthat is actually present and actually malformed:Secondary — if synthesis stays, do not target the boundary.
Budget the synthesized length below the server's real bound rather than aiming at exactly 64, and tighten
RESPONSES_FUNCTION_CALL_IDto the same bound. Today the validator accepts a 64-character value the server rejects, so Atomic's own check cannot catch this class of failure. Validator and generator should agree, and both should sit under the server limit rather than on it.Tertiary, upstream — consider splitting Copilot composite identifiers.
Evaluate whether
github-copilotbelongs inOPENAI_TOOL_CALL_PROVIDERS. With it included,callId|itemIdwould be split and normalized per part instead of flattened into one truncated blob, and the|boundary would survive. This is a broader behavioral change than the primary fix and should not be made solely to address this report.Regression test.
Replay a
function_callitem whoseidis absent and whosecall_idis a 64-character sanitized Copilot-style value. Assert that the sanitizer emits noid, and in particular nofc_*value at or above the server's length bound. The test should fail against 0.9.12.Acceptance criteria
function_callitem whoseidis absent passes throughsanitizeOpenAIResponsesPayloadwithidstill absent.function_callitem whoseidis present and malformed is still repaired, so the original normalization fix does not regress.function_call.idat or above the server's accepted length bound.RESPONSES_FUNCTION_CALL_IDandresponsesFunctionCallIdForCallIdagree on a single maximum length, and that maximum is below the server bound rather than equal to it.idpaired with a 64-character sanitized Copilot-stylecall_id, and fails against 0.9.12.github-copilotopenai-responsessession that issues tool calls completes a multi-turn conversation with replayed tool calls and receives noinvalid_request_body400 — run with a model-identity mismatch in place (mid-session model switch, or an aliased model id), since without one the normalizer never runs and the check passes trivially.Privacy note
This report was produced from a local session transcript and a local package installation.
Every identifier shown here is a fake placeholder written for this document. No real session identifiers, message identifiers, tool-call identifiers, or fragments of them appear. No prompt content, task content, repository name, file paths from the reporting machine, usernames, or credentials are included. Package paths are given relative to the installed package root.
Counts, lengths, timestamps, and version numbers are reported as measured, because they carry the technical argument and identify nothing.
Steps to reproduce
Steps to reproduce / minimal conditions
No special prompt content is needed. The conditions are structural, but note condition 3 — without it the failure does not occur:
openai-responsesmodel served by thegithub-copilotprovider.call_AAAAAAAAAAAAAAAAAAAAAAAA|PLACEHOLDER_OPAQUE_BASE64_BLOB...— a shortcall_segment, a|separator, and a long opaque base64 segment containing+and/.provider,api, andmodelid together — see step A below.inputitems in a later request.function_callitem fails with the 400 above.The trigger is a Copilot tool-call identifier long enough that its normalized form reaches the 64-character truncation limit, replayed across a model-identity mismatch. In the observed session every identifier was long enough, and the mismatch held for every replayed message.
Without the mismatch in condition 3 the normalizer never runs, the emitted id is a 32-character
fc_call_<24 alnum>, and no 400 is possible. This was checked by replaying the shipped conversion over the same 126 identifiers both ways: with the mismatch every emitted id is 64 characters and both rejected identifiers reconstruct exactly; without it every emitted id is 32 characters and neither reconstructs.Expected behavior
Expected behavior
The request should succeed. Atomic should not fill in a
function_call.idthat the conversion layer beneath it left absent, and it should never emit an identifier its own validator accepts but the server rejects.Version
0.9.12