@opencode/ai 0.0.0-dev-20017 → 0.0.0-dev-20020

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.
@@ -12,6 +12,37 @@ declare const OpenAIChatCacheControl: Schema.Struct<{
12
12
  readonly type: Schema.Literal<"ephemeral">;
13
13
  readonly ttl: Schema.optional<Schema.String>;
14
14
  }>;
15
+ declare const ReasoningDetail: Schema.Union<readonly [Schema.StructWithRest<Schema.Struct<{
16
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
17
+ readonly format: Schema.optional<Schema.String>;
18
+ readonly index: Schema.optional<Schema.Number>;
19
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
20
+ readonly type: Schema.Literal<"reasoning.text">;
21
+ readonly text: Schema.optional<Schema.String>;
22
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.StructWithRest<Schema.Struct<{
23
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
24
+ readonly format: Schema.optional<Schema.String>;
25
+ readonly index: Schema.optional<Schema.Number>;
26
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
27
+ readonly type: Schema.Literal<"reasoning.summary">;
28
+ readonly summary: Schema.optional<Schema.String>;
29
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.StructWithRest<Schema.Struct<{
30
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
31
+ readonly format: Schema.optional<Schema.String>;
32
+ readonly index: Schema.optional<Schema.Number>;
33
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
34
+ readonly type: Schema.Literal<"reasoning.encrypted">;
35
+ readonly data: Schema.String;
36
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.Struct<{
37
+ readonly type: Schema.Literal<"summary">;
38
+ readonly summary: Schema.String;
39
+ readonly index: Schema.optional<Schema.Number>;
40
+ }>, Schema.Struct<{
41
+ readonly type: Schema.Literal<"encrypted">;
42
+ readonly encrypted: Schema.String;
43
+ readonly index: Schema.optional<Schema.Number>;
44
+ }>]>;
45
+ type ReasoningDetail = Schema.Schema.Type<typeof ReasoningDetail>;
15
46
  export declare const bodyFields: {
16
47
  model: Schema.String;
17
48
  messages: Schema.$Array<Schema.toTaggedUnion<"role", readonly [Schema.Struct<{
@@ -278,7 +309,9 @@ export interface ParserState {
278
309
  readonly finishReason?: FinishReasonDetails;
279
310
  readonly lifecycle: Lifecycle.State;
280
311
  readonly reasoningField?: string;
281
- readonly reasoningDetails: Array<unknown>;
312
+ /** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
313
+ readonly reasoningTextObserved: boolean;
314
+ readonly reasoningDetails: Array<ReasoningDetail>;
282
315
  readonly reasoningDetailsObserved: boolean;
283
316
  readonly reasoningEmitted: boolean;
284
317
  readonly latestToolIndex?: number;
@@ -1,4 +1,4 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Effect, Option, Schema } from "effect";
2
2
  import { Tool } from "@opencode/schema/tool";
3
3
  import { Route } from "../route/client.js";
4
4
  import { Auth } from "../route/auth.js";
@@ -50,6 +50,32 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
50
50
  arguments: Schema.String,
51
51
  }),
52
52
  });
53
+ // `reasoning_details` carries two dialects. OpenRouter's `reasoning.*` entries
54
+ // must be replayed unmodified (`index` included), so they keep every field they
55
+ // arrived with. Kimi's OpenAI-compatible surface streams preserved thinking as
56
+ // bare `summary` / `encrypted` entries keyed by a stream-only `index`; Kimi does
57
+ // not document this publicly, so the handling follows Kimi Code (Kimi's own
58
+ // client): merge summary deltas by `index`, replay without `index`, and always
59
+ // send `reasoning_content` alongside. Anything else is dropped at the boundary.
60
+ const OpenRouterDetailFields = {
61
+ id: Schema.optional(Schema.NullOr(Schema.String)),
62
+ format: Schema.optional(Schema.String),
63
+ index: Schema.optional(Schema.Number),
64
+ signature: Schema.optional(Schema.NullOr(Schema.String)),
65
+ };
66
+ const ReasoningDetail = Schema.Union([
67
+ Schema.StructWithRest(Schema.Struct({ type: Schema.Literal("reasoning.text"), text: Schema.optional(Schema.String), ...OpenRouterDetailFields }), [Schema.Record(Schema.String, Schema.Unknown)]),
68
+ Schema.StructWithRest(Schema.Struct({
69
+ type: Schema.Literal("reasoning.summary"),
70
+ summary: Schema.optional(Schema.String),
71
+ ...OpenRouterDetailFields,
72
+ }), [Schema.Record(Schema.String, Schema.Unknown)]),
73
+ Schema.StructWithRest(Schema.Struct({ type: Schema.Literal("reasoning.encrypted"), data: Schema.String, ...OpenRouterDetailFields }), [Schema.Record(Schema.String, Schema.Unknown)]),
74
+ Schema.Struct({ type: Schema.Literal("summary"), summary: Schema.String, index: Schema.optional(Schema.Number) }),
75
+ Schema.Struct({ type: Schema.Literal("encrypted"), encrypted: Schema.String, index: Schema.optional(Schema.Number) }),
76
+ ]);
77
+ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail);
78
+ const knownReasoningDetails = (details) => details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)));
53
79
  // Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
54
80
  // extension until direct Google OpenAI-compatible routing is supported here:
55
81
  // https://github.com/vercel/ai/issues/11590
@@ -220,10 +246,20 @@ const reasoningDetails = (parts, native, providerMetadataKey) => {
220
246
  return Array.isArray(details) ? details : [];
221
247
  });
222
248
  if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
223
- return observed;
249
+ return knownReasoningDetails(observed).map(lowerReasoningDetail);
224
250
  if (isRecord(native) && Array.isArray(native.reasoning_details))
225
- return native.reasoning_details;
251
+ return knownReasoningDetails(native.reasoning_details).map(lowerReasoningDetail);
252
+ };
253
+ // Kimi rejects its stream-only `index` on requests
254
+ // ("the reasoning_details ... must not contain streaming index").
255
+ const lowerReasoningDetail = (detail) => {
256
+ if (detail.type === "summary")
257
+ return { type: detail.type, summary: detail.summary };
258
+ if (detail.type === "encrypted")
259
+ return { type: detail.type, encrypted: detail.encrypted };
260
+ return detail;
226
261
  };
262
+ const isKimiDetail = (detail) => detail.type === "summary" || detail.type === "encrypted";
227
263
  const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message, options) {
228
264
  const content = [];
229
265
  for (const part of message.content) {
@@ -282,6 +318,10 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
282
318
  return "reasoning_content";
283
319
  if (!fullyStructured || requireReasoning)
284
320
  return "reasoning_content";
321
+ // Kimi always expects `reasoning_content` on replayed assistant messages,
322
+ // even when thinking arrived only through structured details.
323
+ if (details?.some(isKimiDetail))
324
+ return "reasoning_content";
285
325
  })();
286
326
  const reasoningText = (() => {
287
327
  if (configuredField !== undefined)
@@ -705,14 +745,16 @@ const reasoningDelta = (delta, configuredField) => {
705
745
  }
706
746
  return undefined;
707
747
  };
708
- const detailText = (details) => {
748
+ const detailText = (details, hideKimiSummary) => {
709
749
  const text = details.flatMap((detail) => {
710
- if (!isRecord(detail))
711
- return [];
712
- if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text)
713
- return [detail.text];
714
- if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
715
- return [detail.summary];
750
+ if (detail.type === "reasoning.text")
751
+ return detail.text ? [detail.text] : [];
752
+ if (detail.type === "reasoning.summary")
753
+ return detail.summary ? [detail.summary] : [];
754
+ // Kimi streams the full thinking through `reasoning_content` and a separate
755
+ // summary through details; show the summary only when nothing else does.
756
+ if (detail.type === "summary")
757
+ return detail.summary && !hideKimiSummary ? [detail.summary] : [];
716
758
  return [];
717
759
  });
718
760
  if (text.length > 0)
@@ -721,25 +763,48 @@ const detailText = (details) => {
721
763
  const appendReasoningDetails = (result, details) => {
722
764
  for (const detail of details) {
723
765
  const previous = result.at(-1);
724
- if (!isRecord(previous) ||
725
- previous.type !== "reasoning.text" ||
726
- !isRecord(detail) ||
727
- detail.type !== "reasoning.text" ||
728
- conflictingReasoningTextDetails(previous, detail)) {
766
+ const merged = previous === undefined ? undefined : mergeReasoningDetails(previous, detail);
767
+ if (merged === undefined) {
729
768
  result.push(detail);
730
769
  continue;
731
770
  }
732
- result[result.length - 1] = {
771
+ result[result.length - 1] = merged;
772
+ }
773
+ };
774
+ // Consecutive text or summary deltas of the same kind accumulate into one
775
+ // entry; encrypted entries are opaque and never merge.
776
+ const mergeReasoningDetails = (previous, detail) => {
777
+ if (conflictingReasoningDetails(previous, detail))
778
+ return undefined;
779
+ if (previous.type === "reasoning.text" && detail.type === "reasoning.text")
780
+ return {
733
781
  ...previous,
734
- ...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
735
- text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
736
- signature: mergeDetailValue(previous.signature, detail.signature),
737
- format: mergeDetailValue(previous.format, detail.format),
782
+ ...detail,
783
+ text: `${previous.text ?? ""}${detail.text ?? ""}`,
784
+ ...mergeDetailIdentity(previous, detail),
738
785
  };
739
- }
786
+ if (previous.type === "reasoning.summary" && detail.type === "reasoning.summary")
787
+ return {
788
+ ...previous,
789
+ ...detail,
790
+ summary: `${previous.summary ?? ""}${detail.summary ?? ""}`,
791
+ ...mergeDetailIdentity(previous, detail),
792
+ };
793
+ if (previous.type === "summary" && detail.type === "summary")
794
+ return { ...previous, ...detail, summary: previous.summary + detail.summary };
795
+ };
796
+ // The first non-empty signature and format win; a later delta may carry the
797
+ // signature for text that streamed earlier.
798
+ const mergeDetailIdentity = (previous, current) => {
799
+ const signature = mergeDetailValue(previous.signature, current.signature);
800
+ const format = mergeDetailValue(previous.format, current.format);
801
+ return {
802
+ ...(signature === undefined ? {} : { signature }),
803
+ ...(format === undefined ? {} : { format }),
804
+ };
740
805
  };
741
806
  const mergeDetailValue = (previous, current) => previous || current || (previous !== undefined ? previous : current);
742
- const conflictingReasoningTextDetails = (previous, current) => conflictingDetailValue(previous.id, current.id) ||
807
+ const conflictingReasoningDetails = (previous, current) => conflictingDetailValue(previous.id, current.id) ||
743
808
  conflictingDetailValue(previous.index, current.index) ||
744
809
  conflictingDetailValue(previous.format, current.format) ||
745
810
  (Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature);
@@ -795,12 +860,17 @@ const step = (state, event) => Effect.gen(function* () {
795
860
  return [{ ...state, usage }, events];
796
861
  }
797
862
  const reasoningField = state.reasoningField ?? reasoning?.field;
798
- const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined;
863
+ const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined;
864
+ const detailDelta = Array.isArray(delta?.reasoning_details)
865
+ ? knownReasoningDetails(delta.reasoning_details)
866
+ : undefined;
799
867
  if (detailDelta !== undefined)
800
868
  appendReasoningDetails(state.reasoningDetails, detailDelta);
801
869
  const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined;
802
870
  const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField);
803
- const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text;
871
+ const text = detailDelta?.length
872
+ ? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
873
+ : reasoning?.text;
804
874
  if (text !== undefined)
805
875
  lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata);
806
876
  else if (reasoningDetailsObserved &&
@@ -878,6 +948,7 @@ const step = (state, event) => Effect.gen(function* () {
878
948
  finishReason,
879
949
  lifecycle,
880
950
  reasoningField,
951
+ reasoningTextObserved,
881
952
  reasoningDetails: state.reasoningDetails,
882
953
  reasoningDetailsObserved,
883
954
  reasoningEmitted,
@@ -944,6 +1015,7 @@ export const protocol = Protocol.make({
944
1015
  toolCallEvents: [],
945
1016
  lifecycle: Lifecycle.initial(),
946
1017
  reasoningField: request.model.compatibility?.reasoningField,
1018
+ reasoningTextObserved: false,
947
1019
  reasoningDetails: [],
948
1020
  reasoningDetailsObserved: false,
949
1021
  reasoningEmitted: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.0.0-dev-20017",
3
+ "version": "0.0.0-dev-20020",
4
4
  "name": "@opencode/ai",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "devDependencies": {
35
35
  "@clack/prompts": "1.0.0-alpha.1",
36
36
  "@effect/platform-node": "4.0.0-rc.112",
37
- "@opencode/http-recorder": "0.0.0-dev-20017",
37
+ "@opencode/http-recorder": "0.0.0-dev-20020",
38
38
  "@tsconfig/bun": "1.0.9",
39
39
  "@types/bun": "1.4.0",
40
40
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -44,7 +44,7 @@
44
44
  "@aws-sdk/credential-providers": "3.1057.0",
45
45
  "@smithy/eventstream-codec": "4.2.14",
46
46
  "@smithy/util-utf8": "4.2.2",
47
- "@opencode/schema": "0.0.0-dev-20017",
47
+ "@opencode/schema": "0.0.0-dev-20020",
48
48
  "aws4fetch": "1.0.20",
49
49
  "effect": "4.0.0-rc.112",
50
50
  "google-auth-library": "10.5.0"