@tormentalabs/claude-code-wire-compat 0.1.0 → 0.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +14 -1
  3. package/dist/betas.d.ts +37 -0
  4. package/dist/betas.d.ts.map +1 -1
  5. package/dist/betas.js +55 -23
  6. package/dist/betas.js.map +1 -1
  7. package/dist/build-request.d.ts +11 -0
  8. package/dist/build-request.d.ts.map +1 -1
  9. package/dist/build-request.js +133 -11
  10. package/dist/build-request.js.map +1 -1
  11. package/dist/contracts.d.ts +53 -0
  12. package/dist/contracts.d.ts.map +1 -1
  13. package/dist/contracts.js.map +1 -1
  14. package/dist/fingerprint.d.ts +29 -2
  15. package/dist/fingerprint.d.ts.map +1 -1
  16. package/dist/fingerprint.js +60 -7
  17. package/dist/fingerprint.js.map +1 -1
  18. package/dist/headers.d.ts.map +1 -1
  19. package/dist/headers.js +12 -4
  20. package/dist/headers.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/model-capabilities.d.ts +31 -3
  26. package/dist/model-capabilities.d.ts.map +1 -1
  27. package/dist/model-capabilities.js +145 -12
  28. package/dist/model-capabilities.js.map +1 -1
  29. package/dist/models.d.ts.map +1 -1
  30. package/dist/models.js +8 -4
  31. package/dist/models.js.map +1 -1
  32. package/dist/profile-behaviors.d.ts +61 -0
  33. package/dist/profile-behaviors.d.ts.map +1 -0
  34. package/dist/profile-behaviors.js +53 -0
  35. package/dist/profile-behaviors.js.map +1 -0
  36. package/dist/profiles/beta-registry-2.1.233.d.ts +140 -0
  37. package/dist/profiles/beta-registry-2.1.233.d.ts.map +1 -0
  38. package/dist/profiles/beta-registry-2.1.233.js +183 -0
  39. package/dist/profiles/beta-registry-2.1.233.js.map +1 -0
  40. package/dist/profiles/claude-code-2.1.195.d.ts.map +1 -1
  41. package/dist/profiles/claude-code-2.1.195.js +14 -0
  42. package/dist/profiles/claude-code-2.1.195.js.map +1 -1
  43. package/dist/profiles/claude-code-2.1.233.d.ts +3 -0
  44. package/dist/profiles/claude-code-2.1.233.d.ts.map +1 -0
  45. package/dist/profiles/claude-code-2.1.233.js +235 -0
  46. package/dist/profiles/claude-code-2.1.233.js.map +1 -0
  47. package/dist/redaction.d.ts.map +1 -1
  48. package/dist/redaction.js +14 -1
  49. package/dist/redaction.js.map +1 -1
  50. package/dist/request-body.d.ts.map +1 -1
  51. package/dist/request-body.js +12 -10
  52. package/dist/request-body.js.map +1 -1
  53. package/dist/thinking.d.ts +33 -7
  54. package/dist/thinking.d.ts.map +1 -1
  55. package/dist/thinking.js +105 -36
  56. package/dist/thinking.js.map +1 -1
  57. package/package.json +9 -2
  58. package/src/betas.ts +106 -23
  59. package/src/build-request.ts +155 -11
  60. package/src/contracts.ts +53 -0
  61. package/src/fingerprint.ts +78 -8
  62. package/src/headers.ts +10 -4
  63. package/src/index.ts +1 -0
  64. package/src/model-capabilities.ts +171 -13
  65. package/src/models.ts +8 -4
  66. package/src/profile-behaviors.ts +114 -0
  67. package/src/profiles/beta-registry-2.1.233.ts +200 -0
  68. package/src/profiles/claude-code-2.1.195.ts +14 -0
  69. package/src/profiles/claude-code-2.1.233.ts +240 -0
  70. package/src/redaction.ts +16 -1
  71. package/src/request-body.ts +16 -9
  72. package/src/thinking.ts +119 -39
package/src/betas.ts CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  supportsMidConversationSystem,
11
11
  supportsStructuredOutputs,
12
12
  } from "./model-capabilities.js";
13
+ import { BETA_REGISTRY_2_1_233 } from "./profiles/beta-registry-2.1.233.js";
13
14
  import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
15
+ import { CLAUDE_CODE_2_1_233_PROFILE } from "./profiles/claude-code-2.1.233.js";
14
16
 
15
17
  /*
16
18
  * Provenance. The emitted order is a port of the genuine client's base beta set
@@ -22,7 +24,73 @@ import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
22
24
  * keeps a canonical list, so the sequence below is load-bearing and must not be
23
25
  * reordered for tidiness. `docs/source-trace.md` records the same fact under
24
26
  * "Beta registry and push order".
27
+ *
28
+ * The push SITES are shared across profiles; the identifiers they push are not.
29
+ * A profile therefore selects its registry (see `resolveBetaRegistry`) and the
30
+ * sequence of sites stays fixed, which is what keeps a registry change from
31
+ * silently becoming an ordering change.
32
+ */
33
+
34
+ /** Structural shape of a registry entry, shared by every registry version. */
35
+ interface BetaRegistryEntry {
36
+ readonly featureKey: string;
37
+ readonly header: string;
38
+ }
39
+
40
+ /**
41
+ * The entries the push sites below require, as a structural contract rather
42
+ * than a reference to one concrete registry.
43
+ *
44
+ * Registry versions have different key sets. Every key here except
45
+ * `NARRATION_SUMMARIES` is present in all of them, so those push sites index
46
+ * directly. `NARRATION_SUMMARIES` is optional because upstream removed it after
47
+ * 2.1.195 (see `src/profiles/beta-registry-2.1.233.ts`): its push site survives
48
+ * and becomes inert when the resolved registry has no entry to push. Making the
49
+ * optionality part of the TYPE is what forces every future registry to be
50
+ * checked against the push sites at compile time instead of at runtime.
51
+ */
52
+ export interface ComposableBetaRegistry {
53
+ readonly CLAUDE_CODE: BetaRegistryEntry;
54
+ readonly OAUTH_AUTH: BetaRegistryEntry;
55
+ readonly LONG_CONTEXT: BetaRegistryEntry;
56
+ readonly INTERLEAVED_THINKING: BetaRegistryEntry;
57
+ readonly REDACT_THINKING: BetaRegistryEntry;
58
+ readonly THINKING_TOKEN_COUNT: BetaRegistryEntry;
59
+ readonly CONTEXT_MANAGEMENT: BetaRegistryEntry;
60
+ readonly STRUCTURED_OUTPUTS: BetaRegistryEntry;
61
+ readonly PROMPT_CACHING_SCOPE: BetaRegistryEntry;
62
+ readonly MID_CONVERSATION_SYSTEM: BetaRegistryEntry;
63
+ readonly EFFORT: BetaRegistryEntry;
64
+ readonly SPEED: BetaRegistryEntry;
65
+ readonly AFK_MODE: BetaRegistryEntry;
66
+ readonly EXTENDED_CACHE_TTL: BetaRegistryEntry;
67
+ readonly CONTEXT_HINT: BetaRegistryEntry;
68
+ readonly CACHE_DIAGNOSIS: BetaRegistryEntry;
69
+ readonly NARRATION_SUMMARIES?: BetaRegistryEntry;
70
+ }
71
+
72
+ const PROFILE_BETA_REGISTRIES: ReadonlyMap<string, ComposableBetaRegistry> =
73
+ new Map<string, ComposableBetaRegistry>([
74
+ [CLAUDE_CODE_2_1_195_PROFILE.id, BETA_REGISTRY],
75
+ [CLAUDE_CODE_2_1_233_PROFILE.id, BETA_REGISTRY_2_1_233],
76
+ ]);
77
+
78
+ /**
79
+ * Selects the registry a profile composes against.
80
+ *
81
+ * An unrecognised id falls back to the 2.1.195 registry rather than throwing.
82
+ * Rejecting unknown profiles is the request builder's job -- it validates the
83
+ * profile before any of this runs -- and duplicating that rejection here would
84
+ * give `composeBetas` a second, differently-worded opinion about profile
85
+ * validity. Standalone callers keep the 2.1.195 behaviour they had before
86
+ * profiles were a parameter.
25
87
  */
88
+ function resolveBetaRegistry(
89
+ profile: ClaudeCodeProtocolProfile,
90
+ ): ComposableBetaRegistry {
91
+ return PROFILE_BETA_REGISTRIES.get(profile.id) ?? BETA_REGISTRY;
92
+ }
93
+
26
94
  export interface ComposeBetasInput {
27
95
  readonly rawModel: string;
28
96
  readonly normalizedId: string;
@@ -109,23 +177,24 @@ export function composeBetasWithAudit(
109
177
  const out: string[] = [];
110
178
  const policy = profile.betaPolicy;
111
179
  const experimental = policy.experimentalBetasEnabled;
180
+ const registry = resolveBetaRegistry(profile);
112
181
 
113
182
  if (!input.normalizedId.includes("haiku"))
114
- out.push(BETA_REGISTRY.CLAUDE_CODE.header);
115
- if (policy.oauthAuthenticated) out.push(BETA_REGISTRY.OAUTH_AUTH.header);
183
+ out.push(registry.CLAUDE_CODE.header);
184
+ if (policy.oauthAuthenticated) out.push(registry.OAUTH_AUTH.header);
116
185
  // Package extension: `use1MContextOverride` replaces the model-marker gate
117
186
  // for this request. The profile gate still applies, so an override cannot
118
187
  // enable a beta the pinned profile declares unavailable.
119
188
  const oneMillionRequested =
120
189
  input.use1MContextOverride ?? /\[1m\]/iu.test(input.rawModel);
121
190
  if (policy.oneMillionContextEnabled && oneMillionRequested) {
122
- out.push(BETA_REGISTRY.LONG_CONTEXT.header);
191
+ out.push(registry.LONG_CONTEXT.header);
123
192
  }
124
193
  if (
125
194
  policy.interleavedThinkingEnabled &&
126
195
  input.capabilities.interleavedThinking
127
196
  ) {
128
- out.push(BETA_REGISTRY.INTERLEAVED_THINKING.header);
197
+ out.push(registry.INTERLEAVED_THINKING.header);
129
198
  }
130
199
  if (
131
200
  experimental &&
@@ -134,52 +203,66 @@ export function composeBetasWithAudit(
134
203
  !policy.thinkingSummariesShown &&
135
204
  !input.thinkingDisplayActive
136
205
  ) {
137
- out.push(BETA_REGISTRY.REDACT_THINKING.header);
206
+ out.push(registry.REDACT_THINKING.header);
138
207
  }
139
208
  if (
140
209
  policy.thinkingTokenCountEnabled &&
141
210
  experimental &&
142
211
  input.capabilities.interleavedThinking
143
212
  ) {
144
- out.push(BETA_REGISTRY.THINKING_TOKEN_COUNT.header);
213
+ out.push(registry.THINKING_TOKEN_COUNT.header);
214
+ }
215
+ /*
216
+ * The narration push site keeps its position in the sequence even when the
217
+ * resolved registry dropped the entry. Upstream removed the beta after
218
+ * 2.1.195, so a registry without it emits nothing here and the surrounding
219
+ * order closes up with no gap; the gates are still evaluated first so a
220
+ * profile that enables narration against a registry that has it behaves
221
+ * exactly as it did before.
222
+ */
223
+ const narrationSummaries = registry.NARRATION_SUMMARIES;
224
+ if (
225
+ experimental &&
226
+ policy.narrationSummariesEnabled &&
227
+ narrationSummaries !== undefined
228
+ ) {
229
+ out.push(narrationSummaries.header);
145
230
  }
146
- if (experimental && policy.narrationSummariesEnabled)
147
- out.push(BETA_REGISTRY.NARRATION_SUMMARIES.header);
148
231
  if (experimental && input.capabilities.contextManagement)
149
- out.push(BETA_REGISTRY.CONTEXT_MANAGEMENT.header);
232
+ out.push(registry.CONTEXT_MANAGEMENT.header);
150
233
  if (
151
234
  experimental &&
152
235
  supportsStructuredOutputs(input.normalizedId) &&
153
236
  policy.structuredOutputsEnabled
154
237
  ) {
155
- out.push(BETA_REGISTRY.STRUCTURED_OUTPUTS.header);
238
+ out.push(registry.STRUCTURED_OUTPUTS.header);
156
239
  }
157
240
 
158
241
  // No web-search beta: upstream pushes it only for vertex and foundry.
159
- if (experimental) out.push(BETA_REGISTRY.PROMPT_CACHING_SCOPE.header);
160
- if (supportsMidConversationSystem(input.normalizedId))
161
- out.push(BETA_REGISTRY.MID_CONVERSATION_SYSTEM.header);
162
- if (input.capabilities.effort) out.push(BETA_REGISTRY.EFFORT.header);
242
+ if (experimental) out.push(registry.PROMPT_CACHING_SCOPE.header);
243
+ if (supportsMidConversationSystem(input.normalizedId, profile))
244
+ out.push(registry.MID_CONVERSATION_SYSTEM.header);
245
+ if (input.capabilities.effort) out.push(registry.EFFORT.header);
163
246
 
164
- if (input.speed === "fast" && !out.includes(BETA_REGISTRY.SPEED.header)) {
165
- out.push(BETA_REGISTRY.SPEED.header);
247
+ if (input.speed === "fast" && !out.includes(registry.SPEED.header)) {
248
+ out.push(registry.SPEED.header);
166
249
  }
167
- if (policy.afkModeEnabled && !out.includes(BETA_REGISTRY.AFK_MODE.header)) {
168
- out.push(BETA_REGISTRY.AFK_MODE.header);
250
+ if (policy.afkModeEnabled && !out.includes(registry.AFK_MODE.header)) {
251
+ out.push(registry.AFK_MODE.header);
169
252
  }
170
253
  if (
171
254
  input.cacheTtl === "1h" &&
172
255
  experimental &&
173
- !out.includes(BETA_REGISTRY.EXTENDED_CACHE_TTL.header)
256
+ !out.includes(registry.EXTENDED_CACHE_TTL.header)
174
257
  ) {
175
- out.push(BETA_REGISTRY.EXTENDED_CACHE_TTL.header);
258
+ out.push(registry.EXTENDED_CACHE_TTL.header);
176
259
  }
177
- if (profile.contextHintEnabled) out.push(BETA_REGISTRY.CONTEXT_HINT.header);
260
+ if (profile.contextHintEnabled) out.push(registry.CONTEXT_HINT.header);
178
261
  if (
179
262
  policy.cacheDiagnosisEnabled &&
180
- !out.includes(BETA_REGISTRY.CACHE_DIAGNOSIS.header)
263
+ !out.includes(registry.CACHE_DIAGNOSIS.header)
181
264
  ) {
182
- out.push(BETA_REGISTRY.CACHE_DIAGNOSIS.header);
265
+ out.push(registry.CACHE_DIAGNOSIS.header);
183
266
  }
184
267
 
185
268
  // No advisor-tool beta: upstream has no observed unconditional push site.
@@ -29,6 +29,7 @@ import {
29
29
  } from "./metadata.js";
30
30
  import { resolveModel } from "./models.js";
31
31
  import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
32
+ import { CLAUDE_CODE_2_1_233_PROFILE } from "./profiles/claude-code-2.1.233.js";
32
33
  import type { NormalizedRequestInput } from "./redaction.js";
33
34
  import { buildRedactedEvidence, toSafeErrorDetails } from "./redaction.js";
34
35
  import {
@@ -87,6 +88,8 @@ const INPUT_KEYS = new Set([
87
88
  "metadataOverrides",
88
89
  "extraHeaders",
89
90
  "extraHeaderPolicy",
91
+ "previousRequestId",
92
+ "promptId",
90
93
  "crypto",
91
94
  ]);
92
95
  const BETA_OVERRIDE_KEYS = new Set(["use1MContext"]);
@@ -172,6 +175,7 @@ const MODEL_KEYS = new Set([
172
175
  "family",
173
176
  "context",
174
177
  "capabilities",
178
+ "maxOutputTokens",
175
179
  "defaultEffort",
176
180
  ]);
177
181
  const BETA_POLICY_KEYS = new Set([
@@ -313,10 +317,46 @@ function containsString(value: unknown, target: string): boolean {
313
317
  );
314
318
  }
315
319
 
320
+ /**
321
+ * The profiles this package will assemble a request for. Two entries: the
322
+ * 2.1.195 default and the 2.1.233 profile, which callers must pass
323
+ * explicitly. Admitting a profile is exactly this list -- `validateProfile`
324
+ * did not change to accept the second one.
325
+ *
326
+ * Membership is by REFERENCE, deliberately. A structural check would accept a
327
+ * caller-built object that merely looks like a pinned profile, and every wire
328
+ * guarantee this package makes -- the sealed golden fixtures, the packed
329
+ * consumer digests -- is a statement about the exact frozen singletons, not
330
+ * about anything shaped like them. `Set.prototype.has` uses SameValueZero, so
331
+ * `{ ...CLAUDE_CODE_2_1_195_PROFILE }` is rejected exactly as it was by the
332
+ * `!==` this replaced.
333
+ *
334
+ * Not exported and not frozen-with-teeth: `Object.freeze` on a `Set` blocks
335
+ * property assignment but NOT `add`, so freezing it would advertise a
336
+ * guarantee it cannot keep. Module scope is the real protection.
337
+ */
338
+ const ACCEPTED_PROFILES: ReadonlySet<ClaudeCodeProtocolProfile> = new Set([
339
+ CLAUDE_CODE_2_1_195_PROFILE,
340
+ CLAUDE_CODE_2_1_233_PROFILE,
341
+ ]);
342
+
343
+ /**
344
+ * The profile every public entry point resolves to when the caller supplies
345
+ * none. Declared once so that the default is a single, greppable seam: a test
346
+ * that means "whatever the default is" reads THIS instead of naming a
347
+ * version, which keeps a default switch to a one-line diff and keeps tests
348
+ * that genuinely mean 2.1.195 honest about saying so.
349
+ *
350
+ * Exported for tests, which deep-import it. It is deliberately NOT re-exported
351
+ * from `src/index.ts`: the public runtime surface stays closed.
352
+ */
353
+ export const DEFAULT_PROFILE: ClaudeCodeProtocolProfile =
354
+ CLAUDE_CODE_2_1_233_PROFILE;
355
+
316
356
  function validateProfile(
317
357
  profile: ClaudeCodeProtocolProfile,
318
358
  ): ClaudeCodeProtocolProfile {
319
- if (profile !== CLAUDE_CODE_2_1_195_PROFILE) fail();
359
+ if (!ACCEPTED_PROFILES.has(profile)) fail();
320
360
  return profile;
321
361
  }
322
362
 
@@ -362,6 +402,43 @@ function parseCatalogueCapabilities(value: unknown): readonly string[] {
362
402
  return Object.freeze([...value]);
363
403
  }
364
404
 
405
+ /**
406
+ * Validates a catalogue entry's `maxOutputTokens`. Both fields are required
407
+ * when the object is present: a half-populated entry would silently fall back
408
+ * to the legacy limit table for the missing half, which is exactly the drift
409
+ * `modelOutputTokenLimits` is structured to prevent.
410
+ */
411
+ function parseCatalogueMaxOutputTokens(value: unknown): Readonly<{
412
+ readonly default: number;
413
+ readonly upper: number;
414
+ }> {
415
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
416
+ throw new ClaudeCodeWireError("INVALID_INPUT");
417
+ }
418
+ const keys = Reflect.ownKeys(value);
419
+ if (
420
+ keys.some(
421
+ (key) =>
422
+ typeof key !== "string" || (key !== "default" && key !== "upper"),
423
+ )
424
+ ) {
425
+ throw new ClaudeCodeWireError("INVALID_INPUT");
426
+ }
427
+ const defaultLimit: unknown = Reflect.get(value, "default");
428
+ const upper: unknown = Reflect.get(value, "upper");
429
+ if (
430
+ typeof defaultLimit !== "number" ||
431
+ !Number.isSafeInteger(defaultLimit) ||
432
+ defaultLimit <= 0 ||
433
+ typeof upper !== "number" ||
434
+ !Number.isSafeInteger(upper) ||
435
+ upper <= 0
436
+ ) {
437
+ throw new ClaudeCodeWireError("INVALID_INPUT");
438
+ }
439
+ return Object.freeze({ default: defaultLimit, upper });
440
+ }
441
+
365
442
  function parseCatalogueContext(value: unknown): Readonly<{
366
443
  readonly window: number;
367
444
  readonly native1m?: boolean;
@@ -487,6 +564,13 @@ function parseSupportedModels(
487
564
  ? { context: parseCatalogueContext(ownValue(model, "context")) }
488
565
  : {}),
489
566
  capabilities: parseCatalogueCapabilities(ownValue(model, "capabilities")),
567
+ ...(Object.hasOwn(model, "maxOutputTokens")
568
+ ? {
569
+ maxOutputTokens: parseCatalogueMaxOutputTokens(
570
+ ownValue(model, "maxOutputTokens"),
571
+ ),
572
+ }
573
+ : {}),
490
574
  ...(Object.hasOwn(model, "defaultEffort")
491
575
  ? {
492
576
  defaultEffort: parseDefaultEffort(ownValue(model, "defaultEffort")),
@@ -681,6 +765,8 @@ function validateInput(input: ClaudeCodeRequestInput): {
681
765
  readonly suppressBillingBlock: boolean;
682
766
  readonly suppressIdentityBlock: boolean;
683
767
  readonly preserveThinkingBlockCacheControl: boolean;
768
+ readonly previousRequestId: string | undefined;
769
+ readonly promptId: string | undefined;
684
770
  } {
685
771
  if (!isRecord(input)) fail();
686
772
  assertExactKeys(input, INPUT_KEYS);
@@ -741,6 +827,12 @@ function validateInput(input: ClaudeCodeRequestInput): {
741
827
  ownValue(input, "preserveThinkingBlockCacheControl"),
742
828
  )
743
829
  : false;
830
+ const previousRequestId = Object.hasOwn(input, "previousRequestId")
831
+ ? validateBillingChainId(ownValue(input, "previousRequestId"))
832
+ : undefined;
833
+ const promptId = Object.hasOwn(input, "promptId")
834
+ ? validateBillingChainId(ownValue(input, "promptId"))
835
+ : undefined;
744
836
  return {
745
837
  source: input,
746
838
  clientRequestId,
@@ -751,9 +843,31 @@ function validateInput(input: ClaudeCodeRequestInput): {
751
843
  suppressBillingBlock,
752
844
  suppressIdentityBlock,
753
845
  preserveThinkingBlockCacheControl,
846
+ previousRequestId,
847
+ promptId,
754
848
  };
755
849
  }
756
850
 
851
+ /**
852
+ * Type check only. The FORMAT of these two ids is deliberately not checked
853
+ * here: upstream guards them at the point of emission and drops a value it
854
+ * cannot vouch for, silently, so rejecting one here would make this package
855
+ * fail where the genuine client succeeds. `createBillingBlock` owns the
856
+ * patterns. A non-string is still a caller bug and fails like every other
857
+ * mistyped field.
858
+ *
859
+ * There is deliberately no `undefined` arm: `inspectGraph` has already rejected
860
+ * an explicitly-undefined value for every key but `crypto` by the time this
861
+ * runs, so such an arm would be unreachable. An explicitly-undefined id is
862
+ * therefore `INVALID_INPUT` here, as it is for every other field, and is NOT
863
+ * equivalent to omitting the key — unlike at the `createBillingBlock` seam,
864
+ * which does treat the two alike. `billing-prev-req.test.ts` pins both halves.
865
+ */
866
+ function validateBillingChainId(value: unknown): string {
867
+ if (typeof value !== "string") fail();
868
+ return value;
869
+ }
870
+
757
871
  function validateCountTokensInput(input: ClaudeCodeCountTokensInput): {
758
872
  readonly source: ClaudeCodeCountTokensInput;
759
873
  readonly clientRequestId: string;
@@ -947,7 +1061,19 @@ function parseCapabilityDecisions(
947
1061
  };
948
1062
  }
949
1063
 
950
- function parseEvidence(value: unknown): RedactedRequestEvidence {
1064
+ /**
1065
+ * Validates evidence against the profile the request was parsed under, not
1066
+ * against a hardcoded singleton. `parseBuiltClaudeCodeRequest` already
1067
+ * validates `url` against `pinnedProfile.endpoint`; the profile id is the one
1068
+ * remaining field where the two pinned profiles differ, so it has to follow
1069
+ * the same source or a request built with a non-default profile could never
1070
+ * be re-parsed. Still fail-closed: the profile reaching here has already
1071
+ * passed `validateProfile`.
1072
+ */
1073
+ function parseEvidence(
1074
+ value: unknown,
1075
+ pinnedProfile: ClaudeCodeProtocolProfile,
1076
+ ): RedactedRequestEvidence {
951
1077
  if (!isRecord(value)) fail();
952
1078
  assertExactKeys(value, EVIDENCE_KEYS);
953
1079
  const modelFamily = ownValue(value, "modelFamily");
@@ -966,8 +1092,8 @@ function parseEvidence(value: unknown): RedactedRequestEvidence {
966
1092
  const messageCount = ownValue(value, "messageCount");
967
1093
  const systemBlockCount = ownValue(value, "systemBlockCount");
968
1094
  if (
969
- ownValue(value, "profileId") !== CLAUDE_CODE_2_1_195_PROFILE.id ||
970
- ownValue(value, "url") !== CLAUDE_CODE_2_1_195_PROFILE.endpoint ||
1095
+ ownValue(value, "profileId") !== pinnedProfile.id ||
1096
+ ownValue(value, "url") !== pinnedProfile.endpoint ||
971
1097
  ownValue(value, "method") !== METHOD ||
972
1098
  typeof bodySha256 !== "string" ||
973
1099
  !/^[0-9a-f]{64}$/u.test(bodySha256) ||
@@ -981,8 +1107,8 @@ function parseEvidence(value: unknown): RedactedRequestEvidence {
981
1107
  fail();
982
1108
  }
983
1109
  return {
984
- profileId: CLAUDE_CODE_2_1_195_PROFILE.id,
985
- url: CLAUDE_CODE_2_1_195_PROFILE.endpoint,
1110
+ profileId: pinnedProfile.id,
1111
+ url: pinnedProfile.endpoint,
986
1112
  method: METHOD,
987
1113
  modelFamily,
988
1114
  logicalHeaderNames: parseStringArray(ownValue(value, "logicalHeaderNames")),
@@ -1289,7 +1415,7 @@ function countTokensEvidenceRequest(
1289
1415
  /** Builds a canonical Claude Code count-tokens request. */
1290
1416
  export async function buildClaudeCodeCountTokensRequest(
1291
1417
  input: ClaudeCodeCountTokensInput,
1292
- profile: ClaudeCodeProtocolProfile = CLAUDE_CODE_2_1_195_PROFILE,
1418
+ profile: ClaudeCodeProtocolProfile = DEFAULT_PROFILE,
1293
1419
  ): Promise<BuiltClaudeCodeCountTokensRequest> {
1294
1420
  try {
1295
1421
  const pinnedProfile = validateProfile(profile);
@@ -1384,7 +1510,7 @@ export async function buildClaudeCodeCountTokensRequest(
1384
1510
  */
1385
1511
  export async function buildClaudeCodeRequest(
1386
1512
  input: ClaudeCodeRequestInput,
1387
- profile: ClaudeCodeProtocolProfile = CLAUDE_CODE_2_1_195_PROFILE,
1513
+ profile: ClaudeCodeProtocolProfile = DEFAULT_PROFILE,
1388
1514
  ): Promise<BuiltClaudeCodeRequest> {
1389
1515
  try {
1390
1516
  const pinnedProfile = validateProfile(profile);
@@ -1406,10 +1532,28 @@ export async function buildClaudeCodeRequest(
1406
1532
  ...resolvedModel,
1407
1533
  capabilities,
1408
1534
  });
1535
+ /*
1536
+ * The previous turn's request id is the CALLER's to supply. Upstream
1537
+ * derives it by scanning the conversation for the last assistant message
1538
+ * and reading a `requestId` it stored alongside it — a field of the
1539
+ * client's own transcript, not of the Messages API wire format. Modelling
1540
+ * that would mean adding a non-wire property to `Message` and having this
1541
+ * package infer conversation state it does not own. A documented
1542
+ * divergence of convenience: the value is the same, the plumbing is the
1543
+ * consumer's.
1544
+ */
1409
1545
  const billing = await createBillingBlock(
1410
1546
  fingerprintText(validated.source),
1411
- effectiveProfile.cliVersion,
1547
+ effectiveProfile,
1412
1548
  validated.crypto,
1549
+ {
1550
+ ...(validated.previousRequestId !== undefined && {
1551
+ previousRequestId: validated.previousRequestId,
1552
+ }),
1553
+ ...(validated.promptId !== undefined && {
1554
+ promptId: validated.promptId,
1555
+ }),
1556
+ },
1413
1557
  );
1414
1558
  const metadata = buildCorrelatedMetadata(
1415
1559
  identity,
@@ -1556,7 +1700,7 @@ export async function buildClaudeCodeRequest(
1556
1700
  */
1557
1701
  export function parseBuiltClaudeCodeRequest(
1558
1702
  value: unknown,
1559
- profile: ClaudeCodeProtocolProfile = CLAUDE_CODE_2_1_195_PROFILE,
1703
+ profile: ClaudeCodeProtocolProfile = DEFAULT_PROFILE,
1560
1704
  ): BuiltClaudeCodeRequest {
1561
1705
  try {
1562
1706
  const pinnedProfile = validateProfile(profile);
@@ -1573,7 +1717,7 @@ export function parseBuiltClaudeCodeRequest(
1573
1717
  if (typeof body !== "string") fail();
1574
1718
  const parsedBody = parseBody(body);
1575
1719
  const headers = parseHeaders(ownValue(value, "headers"));
1576
- const evidence = parseEvidence(ownValue(value, "evidence"));
1720
+ const evidence = parseEvidence(ownValue(value, "evidence"), pinnedProfile);
1577
1721
  // Reading evidence is not trusting evidence. A claim that the seam
1578
1722
  // preserved a marker is confirmed against the body, and it is confirmed
1579
1723
  // HERE — before the byte-length and digest checks — so that a forgery which
package/src/contracts.ts CHANGED
@@ -33,6 +33,22 @@ export interface ClaudeCodeCatalogueEntry {
33
33
  }>;
34
34
  /** Verbatim upstream capability keys; compared by the ported predicates. */
35
35
  readonly capabilities: readonly string[];
36
+ /**
37
+ * Per-model output token limits. `default` caps the emitted `max_tokens`;
38
+ * `upper` seeds the thinking budget when the caller supplies none. The
39
+ * field names mirror the upstream 2.1.222 catalogue, where these limits
40
+ * became catalogue data; `modelOutputTokenLimits` keeps returning
41
+ * `{ default, upperLimit }`, so the `upper` -> `upperLimit` rename happens
42
+ * at that one call site in `thinking.ts`.
43
+ *
44
+ * Optional on the type because profiles ported from clients that predate
45
+ * the catalogue limits cannot supply it. Every entry of the 2.1.195
46
+ * catalogue does supply it, and a test enforces that.
47
+ */
48
+ readonly maxOutputTokens?: Readonly<{
49
+ readonly default: number;
50
+ readonly upper: number;
51
+ }>;
36
52
  readonly defaultEffort?: ClaudeCodeEffort;
37
53
  }
38
54
 
@@ -1006,6 +1022,43 @@ export interface ClaudeCodeRequestInput {
1006
1022
  * `"dropConflicting"`.
1007
1023
  */
1008
1024
  readonly extraHeaderPolicy?: ClaudeCodeExtraHeaderPolicy;
1025
+ /**
1026
+ * Supplies the `cc_prev_req` segment of the canonical billing block.
1027
+ *
1028
+ * This is the value of the `request-id` RESPONSE header the API returned for
1029
+ * the PREVIOUS turn of this conversation — not this request's
1030
+ * `clientRequestId`, and not anything derivable from `messages`. The genuine
1031
+ * 2.1.233 client carries it forward so that a multi-turn conversation is
1032
+ * linked request to request on the billing header.
1033
+ *
1034
+ * OMITTING IT IS OBSERVABLE. On the 2.1.233 profile, a second or later turn
1035
+ * built without this field emits a billing block that the genuine client
1036
+ * would not emit, and the request is therefore distinguishable from real CLI
1037
+ * traffic from the second turn onward. The first turn has no previous
1038
+ * request, so omitting it there is correct.
1039
+ *
1040
+ * Ignored entirely by the 2.1.195 profile, which has no such segment.
1041
+ *
1042
+ * A value that does not match `/^req_[A-Za-z0-9_-]{1,36}$/` is SILENTLY
1043
+ * omitted from the block rather than rejected, mirroring upstream: the
1044
+ * genuine client guards the segment and drops a value it cannot vouch for.
1045
+ * A non-string value is still `INVALID_INPUT`, like every other field here.
1046
+ */
1047
+ readonly previousRequestId?: string;
1048
+ /**
1049
+ * Supplies the `cc_prompt_id` segment of the canonical billing block.
1050
+ *
1051
+ * A host-supplied prompt UUID. This package cannot derive one — upstream
1052
+ * reads it from host state — so it is the caller's to provide, and omitting
1053
+ * it is the modelled default for a session that has none.
1054
+ *
1055
+ * Ignored entirely by the 2.1.195 profile, which has no such segment.
1056
+ *
1057
+ * A value that does not match the canonical 8-4-4-4-12 hexadecimal UUID shape
1058
+ * (case-insensitive) is SILENTLY omitted from the block rather than rejected,
1059
+ * mirroring upstream. A non-string value is still `INVALID_INPUT`.
1060
+ */
1061
+ readonly promptId?: string;
1009
1062
  /** Injects the Web Crypto provider used to hash the request body. */
1010
1063
  readonly crypto?: Pick<Crypto, "subtle">;
1011
1064
  }
@@ -1,11 +1,34 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
 
3
- import type { TextBlock } from "./contracts.js";
3
+ import type { ClaudeCodeProtocolProfile, TextBlock } from "./contracts.js";
4
4
  import { ClaudeCodeWireError } from "./contracts.js";
5
- import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
5
+ import { profileBehaviors } from "./profile-behaviors.js";
6
6
 
7
7
  const FINGERPRINT_PREFIX = "59cf53e54c78";
8
8
 
9
+ /**
10
+ * Upstream guard on `cc_prev_req`, transcribed verbatim from the 2.1.233
11
+ * billing-header builder: the segment is emitted only when the value is
12
+ * defined AND matches this pattern AND the request is first-party.
13
+ */
14
+ const PREVIOUS_REQUEST_ID_PATTERN = /^req_[A-Za-z0-9_-]{1,36}$/;
15
+
16
+ /**
17
+ * Upstream guard on `cc_prompt_id`, transcribed verbatim from the same builder.
18
+ * The `i` flag is upstream's, not a relaxation: an upper-case UUID IS emitted.
19
+ */
20
+ const PROMPT_ID_PATTERN =
21
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ /**
24
+ * The conversation-chaining inputs of the 2.1.233 billing block. Both are the
25
+ * caller's to supply; see `ClaudeCodeRequestInput.previousRequestId`.
26
+ */
27
+ export interface BillingChain {
28
+ readonly previousRequestId?: string;
29
+ readonly promptId?: string;
30
+ }
31
+
9
32
  function isCryptoProvider(value: unknown): value is Pick<Crypto, "subtle"> {
10
33
  if (typeof value !== "object" || value === null) {
11
34
  return false;
@@ -66,20 +89,67 @@ export async function createBillingFingerprint(
66
89
  .slice(0, 3);
67
90
  }
68
91
 
92
+ /**
93
+ * Builds the canonical billing block (system index 0).
94
+ *
95
+ * The upstream 2.1.233 builder assembles a fixed prefix followed by five
96
+ * optional segments, each one space-prefixed and semicolon-terminated, in this
97
+ * order: `cch`, `cc_workload`, `cc_is_subagent`, `cc_prev_req`, `cc_prompt_id`.
98
+ *
99
+ * Three of those five are settled for every request this package emits:
100
+ *
101
+ * - `cch=00000;` is always present. Its gate is the first-party predicate,
102
+ * which is true for the Anthropic provider this package targets, and its
103
+ * value is static — the upstream hashed-cache path is dead code.
104
+ * - `cc_workload` and `cc_is_subagent` are never emitted. They describe a
105
+ * background workload and a sub-agent session respectively; this package
106
+ * models the CLI's main session, which has neither. Same position as
107
+ * 2.1.195, which has no such segments at all.
108
+ *
109
+ * The remaining two are conversation state and are the caller's to supply.
110
+ */
69
111
  export async function createBillingBlock(
70
112
  firstUserText: string,
71
- cliVersion: string,
113
+ profile: ClaudeCodeProtocolProfile,
72
114
  crypto?: Pick<Crypto, "subtle">,
115
+ chain?: BillingChain,
73
116
  ): Promise<TextBlock> {
117
+ const { cliVersion, entrypoint } = profile;
74
118
  const fingerprint = await createBillingFingerprint(
75
119
  firstUserText,
76
120
  cliVersion,
77
121
  crypto,
78
122
  );
79
- const { entrypoint } = CLAUDE_CODE_2_1_195_PROFILE;
80
123
 
81
- return {
82
- type: "text",
83
- text: `x-anthropic-billing-header: cc_version=${cliVersion}.${fingerprint}; cc_entrypoint=${entrypoint}; cch=00000;`,
84
- };
124
+ let text = `x-anthropic-billing-header: cc_version=${cliVersion}.${fingerprint}; cc_entrypoint=${entrypoint}; cch=00000;`;
125
+
126
+ /*
127
+ * ---- Demarcated: conversation chaining, upstream 2.1.233. ----
128
+ *
129
+ * The gate is STRUCTURAL, not a capability flag: the 2.1.233 builder takes
130
+ * these two values, the 2.1.195 builder has no parameter for them, so the
131
+ * 195 profile must never emit either segment even when a caller supplies
132
+ * both. They are dropped silently there, exactly as a client without the
133
+ * feature would drop them. Which profiles are on which side is
134
+ * `profile-behaviors.ts`'s question, not this module's.
135
+ *
136
+ * A malformed value is dropped silently too, and never interpolated: these
137
+ * segments are the only caller-controlled bytes in the block, so a value
138
+ * failing its pattern must not reach the wire in any form.
139
+ */
140
+ if (profileBehaviors(profile).billingChainSegments) {
141
+ const previousRequestId = chain?.previousRequestId;
142
+ if (
143
+ previousRequestId !== undefined &&
144
+ PREVIOUS_REQUEST_ID_PATTERN.test(previousRequestId)
145
+ ) {
146
+ text += ` cc_prev_req=${previousRequestId};`;
147
+ }
148
+ const promptId = chain?.promptId;
149
+ if (promptId !== undefined && PROMPT_ID_PATTERN.test(promptId)) {
150
+ text += ` cc_prompt_id=${promptId};`;
151
+ }
152
+ }
153
+
154
+ return { type: "text", text };
85
155
  }