@magpie-community/opencode-kiro-auth 0.1.19 → 0.1.20

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 (3) hide show
  1. package/README.md +80 -1
  2. package/index.mjs +461 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -105,6 +105,12 @@ request:
105
105
  that can't be read or brought under is replaced by a short note, so the
106
106
  conversation goes on (#428). Smaller images are sent as they are.
107
107
  - Tools the history used but the request doesn't offer are declared.
108
+ - A tool's input schema says `type: "object"` at its root, as Kiro
109
+ wants: one that says no type (or object-or-null) is sent saying so, and
110
+ an empty one as an object with no properties. Kiro answers 400
111
+ `REQUEST_BODY_INVALID` to either. Everything under the root (a union
112
+ there, properties, `$ref`s, `nullable`) is sent as given, and a root
113
+ that names another type is left alone.
108
114
  - It turns the AWS event stream Kiro answers with back into Messages'
109
115
  events (streamed) or one message. The reply includes text, thinking,
110
116
  tool calls and usage. When Kiro reports only how full the context is,
@@ -113,12 +119,86 @@ request:
113
119
  the prompt, with a budget: low 10k, medium 20k, high 30k, xhigh and max
114
120
  50k (as magpie's built-in Kiro: #120). The
115
121
  `<thinking>` block of the reply comes back as thinking.
122
+ - Anthropic's hosted web search is answered with Kiro's own web search
123
+ (see [Web search](#web-search)).
116
124
  - Kiro's failures keep their meaning:
117
125
  - input too long → 400
118
126
  - no capacity → 503
119
127
  - usage limit → 429
120
128
  - throttling → "rate limited"
121
129
 
130
+ ## Web search
131
+
132
+ A request offering Anthropic's hosted web search,
133
+ `{"type": "web_search_20250305", "name": "web_search", "max_uses": n}`, is
134
+ searched with Kiro's own web search, sent as kiro-cli (2.35) sends it:
135
+
136
+ - The model is offered kiro-cli's `web_search` tool as kiro-cli's agent
137
+ offers it: its description, a `query` (200 characters at most) and the
138
+ `__tool_use_purpose` kiro-cli adds to each tool. A call the model makes to
139
+ it is searched here, never passed to the caller as a `tool_use`. Its
140
+ `__tool_use_purpose` is kiro-cli's alone: it isn't searched for, and the
141
+ caller's `server_tool_use` has the `query` only.
142
+ - The search is Kiro's runtime's `InvokeMCP` operation:
143
+ `POST runtime.<region>.kiro.dev/`, `Content-Type:
144
+ application/x-amz-json-1.0`, `x-amz-target:
145
+ AmazonCodeWhispererStreamingService.InvokeMCP`, in the account's region
146
+ with its token. Its body is a JSON-RPC `tools/call` of `web_search` with
147
+ `{query}` and nothing else, and the profile as `profileArn` beside it:
148
+ over AWS JSON 1.0 that member is sent in the body, and without it there
149
+ Kiro answers 400 `profileArn is required for this request`. It goes in
150
+ `x-amzn-kiro-profile-arn` too, as kiro-cli 2.35 sends it. An API key,
151
+ sent as `tokentype: API_KEY`, names no profile in either, as kiro-cli
152
+ names none for one. The answer is
153
+ `{jsonrpc, id, result, error}`, the result being the `tools/call`'s.
154
+ - The pages found go back to the model as the call's result (title, URL,
155
+ date, snippet), and it is asked again. The caller is answered as
156
+ Anthropic answers: `server_tool_use` and `web_search_tool_result` blocks
157
+ (streamed or not), then the answer, and `usage.server_tool_use.web_search_requests`.
158
+ - Each result has the page's `title`, `url` and `page_age`. `encrypted_content`
159
+ is `""`: Kiro has no sealed copy of the page, and none is made up (as
160
+ magpie's own search answers). The answer cites its pages by URL in its
161
+ text; it has no `citations`, which need Anthropic's encrypted index.
162
+ - `max_uses` is kept, and no request searches more than 10 times. A search
163
+ past it is a `max_uses_exceeded` error; a model still searching after
164
+ `max_uses` + 2 turns is stopped with `pause_turn`. A `max_uses` that isn't
165
+ a whole number of at least 1 is a 400.
166
+ - `allowed_domains` or `blocked_domains` filter the pages Kiro's search
167
+ returns; the search itself isn't told them, so fewer pages may come back.
168
+ A domain takes in its subdomains; with a path (`example.com/Docs`), only
169
+ pages under it, the host matched in any case and the path as written.
170
+ Both at once, or `user_location`, which Kiro's search has no place for,
171
+ is a 400.
172
+ - A search that fails (Kiro unreachable, an error, an answer it can't
173
+ read, a query over 200 characters) is a `web_search_tool_result_error`,
174
+ and the model is told why. A token Kiro's search turns down (403) is
175
+ refreshed once, as a request's is. A cancelled request stops searching.
176
+ - Kiro failing on a turn after a search is an error: streamed, an `error`
177
+ event after the blocks said; not streamed, the failure's status, not a
178
+ 200 holding only the searches.
179
+ - A turn that also calls the caller's own tools ends there (`tool_use`),
180
+ with its searches. The conversation sent back, with its
181
+ `server_tool_use` and `web_search_tool_result` blocks, gives the model
182
+ each search's call and result again. A tool of the caller's named
183
+ `web_search` (no hosted `type`) stays the caller's.
184
+ - Other hosted tools are not sent, as before.
185
+
186
+ ### Supported scope
187
+
188
+ Only `web_search_20250305` named `web_search` is searched (Claude Code sends
189
+ it). Any other tool whose type begins `web_search` (a newer version such as
190
+ `web_search_20260209`, another name, the same tool twice) is a 400 before
191
+ Kiro is asked: with `searches: true` magpie hands every such tool to the
192
+ plugin rather than searching for it, so dropping it would leave the search
193
+ asked for undone. A client that sends a newer version needs
194
+ `provider.kiro.searches: false`, so magpie's own search serves it as it did
195
+ before; the 400 says so.
196
+
197
+ The `config` hook says `searches: true` on the provider, so magpie passes a
198
+ client's web search (Claude Code's WebSearch) on to Kiro as sent rather than
199
+ searching for it. Set `provider.kiro.searches` to `false` to have magpie's
200
+ own search used instead.
201
+
122
202
  ## Models
123
203
 
124
204
  The `config` hook declares `auto` (Kiro picks the model). Once signed in,
@@ -134,4 +214,3 @@ and Auto have the thinking variants.
134
214
  provider.
135
215
  - Signing in with a company's own identity provider in the browser (use
136
216
  kiro-cli's sign-in).
137
- - magpie's web-search stand-in for Kiro.
package/index.mjs CHANGED
@@ -845,6 +845,21 @@ function formatText(req) {
845
845
  return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + JSON.stringify(f.schema) + "."
846
846
  }
847
847
 
848
+ // inputSchema is a tool's input schema as Kiro takes it: one whose root
849
+ // says type "object". Kiro answers 400 REQUEST_BODY_INVALID to a root with
850
+ // properties and no type (Haiku 5.5: the same schema with the type added
851
+ // is called), and to {}. A tool's input is always an object, so a root
852
+ // that says no type, or object-or-null, is said to be one; the rest of it
853
+ // (a union at its root, its properties, $refs, nullable) is sent as given,
854
+ // and a root that says another type is left as the caller said it. The
855
+ // caller's schema isn't changed: the root is copied.
856
+ function inputSchema(s) {
857
+ if (!s || typeof s !== "object" || Array.isArray(s) || !Object.keys(s).length) return { type: "object", properties: {} }
858
+ if (s.type === undefined) return { type: "object", ...s }
859
+ if (Array.isArray(s.type) && s.type.includes("object") && s.type.every((t) => t === "object" || t === "null")) return { ...s, type: "object" }
860
+ return s
861
+ }
862
+
848
863
  // buildKiro is the body of a generateAssistantResponse call, as it is
849
864
  // before its images are fitted (kiroBody).
850
865
  function buildKiro(req, model, profile, budget) {
@@ -866,26 +881,53 @@ function kiroRequest(req, model, profile, budget) {
866
881
  if (parts.some((p) => p?.cache_control)) marked.add(msg)
867
882
  }
868
883
  const user = () => ({ content: "", modelId: model, origin: "KIRO_CLI" })
884
+ const resultsOnly = new WeakSet() // the messages made to carry a reply's web search results
869
885
  for (const m of req.messages ?? []) {
870
886
  const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
871
887
  const texts = []
872
888
  if (m.role === "assistant") {
873
- const a = { content: "", toolUses: [] }
889
+ // a web search's result is in the reply itself (server_tool_use, then
890
+ // web_search_tool_result): to Kiro the reply up to it is a turn of its
891
+ // own, answered by a message carrying the result
892
+ let a = { content: "", toolUses: [] }
893
+ let last = null
894
+ const flush = () => {
895
+ a.content = texts.splice(0).join("\n\n")
896
+ if (!a.content && !a.toolUses.length) return // what only thought
897
+ const prev = entries.at(-1)?.assistantResponseMessage
898
+ if (prev) {
899
+ prev.content = joinNonEmpty(prev.content, a.content)
900
+ prev.toolUses.push(...a.toolUses)
901
+ } else entries.push({ assistantResponseMessage: a })
902
+ last = prev ?? a
903
+ a = { content: "", toolUses: [] }
904
+ }
874
905
  for (const p of parts) {
875
906
  if (p?.type === "text" && p.text) texts.push(p.text)
876
907
  else if (p?.type === "tool_use") {
877
908
  const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
878
909
  a.toolUses.push({ name: toolName(p.name), toolUseId: toolID(p.id), input })
910
+ } else if (p?.type === "server_tool_use" && p.name === SEARCH_NAME) {
911
+ const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
912
+ a.toolUses.push({ name: SEARCH_NAME, toolUseId: toolID(p.id), input })
913
+ } else if (p?.type === "web_search_tool_result") {
914
+ flush()
915
+ const text = searchedText(p)
916
+ const result = { toolUseId: toolID(p.tool_use_id), status: text === null ? "error" : "success",
917
+ content: [{ text: text ?? searched.get(p.tool_use_id) ?? searchFailedText(p.content?.error_code) }] }
918
+ // parallel searches (both calls, then both results, as
919
+ // Anthropic answers them) are answered in one message
920
+ const prev = entries.at(-1)?.userInputMessage
921
+ if (prev && resultsOnly.has(prev)) prev.userInputMessageContext.toolResults.push(result)
922
+ else {
923
+ const u = { ...user(), images: [], userInputMessageContext: { toolResults: [result] } }
924
+ resultsOnly.add(u)
925
+ entries.push({ userInputMessage: u })
926
+ }
879
927
  }
880
928
  }
881
- a.content = texts.join("\n\n")
882
- if (!a.content && !a.toolUses.length) continue // a turn that only thought
883
- const prev = entries.at(-1)?.assistantResponseMessage
884
- if (prev) {
885
- prev.content = joinNonEmpty(prev.content, a.content)
886
- prev.toolUses.push(...a.toolUses)
887
- } else entries.push({ assistantResponseMessage: a })
888
- mark(prev ?? a, parts)
929
+ flush()
930
+ if (last) mark(last, parts)
889
931
  continue
890
932
  }
891
933
  const u = user()
@@ -981,11 +1023,19 @@ function kiroRequest(req, model, profile, budget) {
981
1023
  const tools = []
982
1024
  const offered = new Set()
983
1025
  const EMPTY = { type: "object", properties: {} }
1026
+ const hosted = hostedSearch(req)
984
1027
  for (const t of req.tools ?? []) {
1028
+ if (hosted && t === hosted.tool) {
1029
+ // Kiro's own web search, run here when the model calls it (searching)
1030
+ offered.add(SEARCH_NAME)
1031
+ tools.push({ toolSpecification: SEARCH_SPEC })
1032
+ if (t.cache_control) tools.push({ cachePoint: { type: "default" } })
1033
+ continue
1034
+ }
985
1035
  if (!t?.name || (t.type && t.type !== "custom" && !t.input_schema)) continue // server tools Kiro hasn't
986
1036
  const name = toolName(t.name)
987
1037
  offered.add(name)
988
- tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: t.input_schema ?? EMPTY } } })
1038
+ tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: inputSchema(t.input_schema) } } })
989
1039
  if (t.cache_control) tools.push({ cachePoint: { type: "default" } })
990
1040
  }
991
1041
  for (const e of entries)
@@ -1016,21 +1066,24 @@ function kiroRequest(req, model, profile, budget) {
1016
1066
  return out
1017
1067
  }
1018
1068
 
1019
- function sendKiro(a, body, signal) {
1069
+ // sdkHeaders are the AWS SDK's headers on a call to Kiro's runtime, as
1070
+ // kiro-cli's client sends them: who is calling, and as which client.
1071
+ function sdkHeaders(a) {
1020
1072
  const ua = `aws-sdk-rust/1.0.0 ua/2.1 os/other lang/rust api/codewhispererstreaming#1.28.3 m/E app/AmazonQ-For-CLI md/appVersion-1.28.3-${randomUUID().replaceAll("-", "")}`
1073
+ return {
1074
+ ...authHeaders(a),
1075
+ "amz-sdk-invocation-id": randomUUID(),
1076
+ "amz-sdk-request": "attempt=1; max=1",
1077
+ "x-amz-user-agent": ua,
1078
+ "User-Agent": ua,
1079
+ }
1080
+ }
1081
+
1082
+ function sendKiro(a, body, signal) {
1021
1083
  return fetch(runtime(a.region) + "/generateAssistantResponse", {
1022
1084
  method: "POST",
1023
- headers: {
1024
- ...authHeaders(a),
1025
- "Content-Type": "application/json",
1026
- Accept: "application/vnd.amazon.eventstream",
1027
- "x-amzn-codewhisperer-optout": "true",
1028
- "amz-sdk-invocation-id": randomUUID(),
1029
- "amz-sdk-request": "attempt=1; max=1",
1030
- "x-amzn-kiro-agent-mode": "vibe",
1031
- "x-amz-user-agent": ua,
1032
- "User-Agent": ua,
1033
- },
1085
+ headers: { ...sdkHeaders(a), "x-amzn-codewhisperer-optout": "true", "x-amzn-kiro-agent-mode": "vibe",
1086
+ "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream" },
1034
1087
  body,
1035
1088
  signal,
1036
1089
  })
@@ -1319,10 +1372,13 @@ function finished(content) {
1319
1372
  // Kiro read from its cache is unknown, not 0: the cache fields are left
1320
1373
  // out and magpie is told so (magpie_cache_unknown), so it shows no hit
1321
1374
  // rate rather than 0%.
1322
- const anthropicUsage = (u) =>
1323
- u.reported
1375
+ const anthropicUsage = (u) => ({
1376
+ ...(u.reported
1324
1377
  ? { input_tokens: u.input, output_tokens: u.output, cache_read_input_tokens: u.cacheRead, cache_creation_input_tokens: u.cacheWrite }
1325
- : { input_tokens: u.input, output_tokens: u.output, magpie_cache_unknown: true }
1378
+ : { input_tokens: u.input, output_tokens: u.output, magpie_cache_unknown: true }),
1379
+ // the hosted web searches made (searching)
1380
+ ...(u.searches ? { server_tool_use: { web_search_requests: u.searches } } : {}),
1381
+ })
1326
1382
 
1327
1383
  // quotaWords are magpie's (internal/gateway/fallback.go): how a vendor says
1328
1384
  // "out of quota" or "slow down".
@@ -1355,11 +1411,16 @@ async function reply(it, model, stream, names = new Map()) {
1355
1411
  else add({ type: "thinking", thinking: e.text, signature: "" })
1356
1412
  } else if (e.kind === "sig") {
1357
1413
  if (last?.type === "thinking") last.signature += e.text
1358
- } else if (e.kind === "toolStart") add({ type: "tool_use", id: e.id, name: names.get(e.name) ?? e.name, input: "" })
1414
+ } else if (e.kind === "serverTool") add({ type: "server_tool_use", id: e.id, name: e.name, input: e.input })
1415
+ else if (e.kind === "searched") add({ type: "web_search_tool_result", tool_use_id: e.id, content: e.content })
1416
+ else if (e.kind === "toolStart") add({ type: "tool_use", id: e.id, name: names.get(e.name) ?? e.name, input: "" })
1359
1417
  else if (e.kind === "toolArgs") {
1360
1418
  if (last?.type === "tool_use") last.input += e.text
1361
1419
  } else if (e.kind === "error") {
1362
- // with none of the answer said, a status; else what was said
1420
+ // with none of the answer said, a status; else what was said. A
1421
+ // turn after a web search that failed is a status even so: what
1422
+ // was said is the searches, without the answer they were for
1423
+ if (e.status) return errorResponse(e.status, e.text)
1363
1424
  if (!content.length) return failedBefore(e.text)
1364
1425
  return Response.json({ id, type: "message", role: "assistant", model, content: finished(content), stop_reason: "end_turn",
1365
1426
  stop_sequence: null, usage: anthropicUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) })
@@ -1421,6 +1482,15 @@ async function reply(it, model, stream, names = new Map()) {
1421
1482
  case "toolArgs":
1422
1483
  if (open === "tool") ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: e.text } }))
1423
1484
  break
1485
+ case "serverTool":
1486
+ begin("server", { type: "server_tool_use", id: e.id, name: e.name, input: {} })
1487
+ ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(e.input) } }))
1488
+ close()
1489
+ break
1490
+ case "searched":
1491
+ begin("searched", { type: "web_search_tool_result", tool_use_id: e.id, content: e.content })
1492
+ close()
1493
+ break
1424
1494
  case "error":
1425
1495
  close()
1426
1496
  ctl.enqueue(sse("error", { error: { type: "api_error", message: e.text } }))
@@ -1439,6 +1509,222 @@ async function reply(it, model, stream, names = new Map()) {
1439
1509
  return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
1440
1510
  }
1441
1511
 
1512
+ // ---- web search: Kiro's own, for Anthropic's hosted tool ----------------------------
1513
+ //
1514
+ // A request offering Anthropic's hosted web search ({type:
1515
+ // "web_search_20250305", name: "web_search"}) has it searched by Kiro's own
1516
+ // web search, as kiro-cli (2.35) searches: its runtime's InvokeMCP
1517
+ // operation (AWS JSON 1.0, x-amz-target
1518
+ // AmazonCodeWhispererStreamingService.InvokeMCP, POSTed to the runtime's
1519
+ // root) carrying a JSON-RPC tools/call of web_search, with the account's
1520
+ // token and profile. The model is offered kiro-cli's web_search tool; a call
1521
+ // it makes to it is searched here, the pages found go back to it as the
1522
+ // call's result and it is asked again, and the caller is answered with
1523
+ // server_tool_use and web_search_tool_result blocks and the answer, as
1524
+ // Anthropic answers. The caller's own tools are never run here.
1525
+
1526
+ const SEARCH_TYPE = "web_search_20250305"
1527
+ const SEARCH_NAME = "web_search"
1528
+ const MCP_TARGET = "AmazonCodeWhispererStreamingService.InvokeMCP"
1529
+ const SEARCH_USES = 10 // searches one request may make at most, max_uses or not
1530
+ const SEARCH_QUERY = 200 // characters: what kiro-cli's web_search says its query takes
1531
+ const SEARCH_TIMEOUT = 30_000
1532
+ const SEARCH_BODY = 1 << 20 // bytes of a search's answer read at most
1533
+ const SEARCH_RESULTS = 20
1534
+ // PURPOSE is what kiro-cli's agent adds to every tool it offers: the model
1535
+ // says why it calls it. It isn't searched for, nor shown to the caller.
1536
+ const PURPOSE = "__tool_use_purpose"
1537
+ // kiro-cli's web_search, as its agent offers it (2.35's own strings)
1538
+ const SEARCH_SPEC = {
1539
+ name: SEARCH_NAME,
1540
+ description: "WebSearch looks up information that is outside the model's training data or cannot be reliably inferred from the current codebase/context.",
1541
+ inputSchema: { json: { type: "object", properties: {
1542
+ query: { type: "string", maxLength: SEARCH_QUERY, description: "Search query (max 200 chars) - use concise keywords, not full sentences" },
1543
+ [PURPOSE]: { type: "string", description: "A brief explanation why you are making this tool use." },
1544
+ }, required: ["query"] } },
1545
+ }
1546
+
1547
+ // hostedSearch is the request's hosted web search, as it asks for it: the
1548
+ // tool, how many searches it allows, and the domains it keeps or drops.
1549
+ // null when it offers none; { error } when it asks for what can't be done.
1550
+ function hostedSearch(req) {
1551
+ // magpie takes any tool whose type begins web_search for a web search and,
1552
+ // as this provider searches (config: searches), passes it here rather
1553
+ // than searching for it: one this can't search with is refused, not
1554
+ // dropped, so a search asked for isn't silently gone
1555
+ const searches = (req.tools ?? []).filter((t) => typeof t?.type === "string" && t.type.startsWith("web_search"))
1556
+ const odd = searches.find((t) => t.type !== SEARCH_TYPE || t.name !== SEARCH_NAME)
1557
+ if (odd) return { error: `the hosted web search on Kiro is {type: "${SEARCH_TYPE}", name: "${SEARCH_NAME}"}, not {type: ${JSON.stringify(odd.type)}, name: ${JSON.stringify(odd.name ?? null)}}; ` +
1558
+ "set provider.kiro.searches to false to have magpie search for it" }
1559
+ if (searches.length > 1) return { error: "web_search is offered more than once" }
1560
+ const tool = searches[0]
1561
+ if (!tool) return null
1562
+ if ((req.tools ?? []).some((t) => t !== tool && toolName(t?.name ?? "") === SEARCH_NAME))
1563
+ return { error: "two tools are named web_search: the hosted web search and another" }
1564
+ // Kiro's search has no place for where the user is
1565
+ if (tool.user_location) return { error: "web_search's user_location isn't supported on Kiro" }
1566
+ const list = (v) => (Array.isArray(v) ? v.filter((d) => typeof d === "string" && d.trim()).map((d) => d.trim()) : [])
1567
+ const allowed = list(tool.allowed_domains)
1568
+ const blocked = list(tool.blocked_domains)
1569
+ if (allowed.length && blocked.length) return { error: "web_search takes allowed_domains or blocked_domains, not both" }
1570
+ // a max_uses given is a whole number of at least 1, as Anthropic's API
1571
+ // takes it; none given is SEARCH_USES, and none is more
1572
+ if (tool.max_uses != null && !(Number.isInteger(tool.max_uses) && tool.max_uses > 0))
1573
+ return { error: "web_search's max_uses must be a whole number of at least 1" }
1574
+ return { tool, uses: Math.min(tool.max_uses ?? SEARCH_USES, SEARCH_USES), allowed, blocked }
1575
+ }
1576
+
1577
+ // a domain as Anthropic's allowed_domains and blocked_domains name one:
1578
+ // the host and its subdomains, and under a path when it gives one (a
1579
+ // host's case doesn't matter, a path's does)
1580
+ function inDomain(url, domain) {
1581
+ const slash = domain.indexOf("/")
1582
+ const host = (slash < 0 ? domain : domain.slice(0, slash)).replace(/^\*\./, "").toLowerCase()
1583
+ const path = slash < 0 ? "" : domain.slice(slash)
1584
+ const h = url.hostname.toLowerCase()
1585
+ return (h === host || h.endsWith("." + host)) && (!path || url.pathname.startsWith(path))
1586
+ }
1587
+
1588
+ // searched is what each search found, by its server_tool_use id, as the
1589
+ // model was given it: a conversation sent again with the result has its
1590
+ // snippets told the model again. Bounded; one not kept is told from the
1591
+ // result's titles and URLs.
1592
+ const searched = new Map()
1593
+ const SEARCHED_KEEP = 64
1594
+ const keepSearched = (id, text) => {
1595
+ searched.set(id, text)
1596
+ if (searched.size > SEARCHED_KEEP) searched.delete(searched.keys().next().value)
1597
+ }
1598
+
1599
+ const SEARCH_ERRORS = {
1600
+ invalid_tool_input: "the search query was missing or not text",
1601
+ query_too_long: `the search query was longer than ${SEARCH_QUERY} characters`,
1602
+ max_uses_exceeded: "no more searches are allowed for this request; answer with what was found",
1603
+ too_many_requests: "the search service is rate limited",
1604
+ unavailable: "the search service is unavailable",
1605
+ }
1606
+ const searchFailedText = (code) => "Web search failed: " + (SEARCH_ERRORS[code] ?? String(code ?? "unavailable"))
1607
+
1608
+ // searchedText is a web_search_tool_result told as text, for the model;
1609
+ // null when it is an error.
1610
+ function searchedText(p) {
1611
+ if (!Array.isArray(p?.content)) return null
1612
+ const kept = searched.get(p.tool_use_id)
1613
+ if (kept) return kept
1614
+ if (!p.content.length) return "No results."
1615
+ return p.content.filter((r) => r?.url).map((r, i) => `[${i + 1}] ${r.title || r.url}\nURL: ${r.url}` +
1616
+ (r.page_age ? `\nPublished: ${r.page_age}` : "")).join("\n\n")
1617
+ }
1618
+
1619
+ // secretless is a message with anything like a token taken out, and short.
1620
+ const secretless = (s) => String(s ?? "").replace(/Bearer\s+\S+/gi, "Bearer …").replace(/\b(ksk_|aoa|aor)[A-Za-z0-9._\-]{8,}/g, "…").slice(0, 200)
1621
+
1622
+ // bounded reads at most limit bytes of a body: null past it.
1623
+ async function bounded(res, limit) {
1624
+ if (!res.body) return ""
1625
+ const reader = res.body.getReader()
1626
+ const parts = []
1627
+ let n = 0
1628
+ for (;;) {
1629
+ const { done, value } = await reader.read()
1630
+ if (done) break
1631
+ n += value.length
1632
+ if (n > limit) {
1633
+ await reader.cancel().catch(() => {})
1634
+ return null
1635
+ }
1636
+ parts.push(value)
1637
+ }
1638
+ return new TextDecoder().decode(Buffer.concat(parts))
1639
+ }
1640
+
1641
+ // webSearch searches with Kiro's web_search through InvokeMCP: { results }
1642
+ // (Anthropic's web_search_result blocks) and the text the model is given,
1643
+ // or { code } (a web_search_tool_result_error's error_code) and why, or
1644
+ // { status: 403 } for a token Kiro turned down. An abort is thrown. The
1645
+ // profile is InvokeMCPRequest's profileArn, which AWS JSON 1.0 sends in the
1646
+ // body: without it there Kiro answers 400 "profileArn is required for this
1647
+ // request", the x-amzn-kiro-profile-arn header (its httpHeader binding in
1648
+ // Kiro's REST model, sent too, as kiro-cli 2.35 sends it) notwithstanding.
1649
+ // An API key names no profile, as kiro-cli names none for one. The tool's
1650
+ // arguments are its query alone.
1651
+ async function webSearch(a, query, hosted, signal) {
1652
+ const timeout = AbortSignal.timeout(SEARCH_TIMEOUT)
1653
+ const both = signal ? AbortSignal.any([signal, timeout]) : timeout
1654
+ const body = { jsonrpc: "2.0", id: "1", method: "tools/call", ...(a.profile ? { profileArn: a.profile } : {}),
1655
+ params: { name: SEARCH_NAME, arguments: { query } } }
1656
+ let res, text
1657
+ try {
1658
+ res = await fetch(runtime(a.region) + "/", { method: "POST", body: JSON.stringify(body), signal: both,
1659
+ headers: { ...sdkHeaders(a), "amz-sdk-request": "attempt=1; max=3", "Content-Type": "application/x-amz-json-1.0",
1660
+ "x-amz-target": MCP_TARGET, ...(a.profile ? { "x-amzn-kiro-profile-arn": a.profile } : {}) } })
1661
+ if (res.status === 403) {
1662
+ await res.body?.cancel().catch(() => {})
1663
+ return { status: 403 }
1664
+ }
1665
+ text = await bounded(res, SEARCH_BODY)
1666
+ } catch (e) {
1667
+ if (signal?.aborted) throw e
1668
+ return { code: "unavailable", why: timeout.aborted ? "the search timed out" : "the search service couldn't be reached: " + secretless(e?.message) }
1669
+ }
1670
+ if (!res.ok) return { code: res.status === 429 ? "too_many_requests" : "unavailable", why: `the search service answered ${res.status}` }
1671
+ if (text === null) return { code: "unavailable", why: "the search's answer was too long" }
1672
+ return searchAnswer(text, hosted)
1673
+ }
1674
+
1675
+ // searchAnswer reads InvokeMCP's answer, { jsonrpc, id, result, error }: the
1676
+ // result is the tools/call's, its content a text block of the search's JSON.
1677
+ function searchAnswer(text, hosted) {
1678
+ let m
1679
+ try {
1680
+ m = JSON.parse(text)
1681
+ } catch {}
1682
+ const bad = (why) => ({ code: "unavailable", why })
1683
+ if (!m || typeof m !== "object") return bad("the search's answer wasn't JSON")
1684
+ if (m.error) return bad("the search failed: " + secretless(m.error.message ?? m.error.code))
1685
+ // a document, which may come as its JSON text
1686
+ let result = m.result
1687
+ if (typeof result === "string")
1688
+ try {
1689
+ result = JSON.parse(result)
1690
+ } catch {}
1691
+ const content = Array.isArray(result?.content) ? result.content : []
1692
+ const out = content.find((c) => c?.type === "text" && typeof c.text === "string")
1693
+ if (result?.isError) return bad("the search failed: " + secretless(out?.text))
1694
+ let r
1695
+ try {
1696
+ r = JSON.parse(out?.text ?? "")
1697
+ } catch {
1698
+ return bad("the search's answer wasn't one it reads")
1699
+ }
1700
+ if (!r || typeof r !== "object" || !Array.isArray(r.results)) return bad("the search's answer had no results list")
1701
+ if (r.error) return bad("the search failed: " + secretless(typeof r.error === "string" ? r.error : r.error.message))
1702
+ const results = []
1703
+ const lines = []
1704
+ for (const h of r.results) {
1705
+ let url
1706
+ try {
1707
+ url = new URL(String(h?.url ?? ""))
1708
+ } catch {
1709
+ continue
1710
+ }
1711
+ if (!/^https?:$/.test(url.protocol)) continue
1712
+ // Kiro's search has no domain filter: what it found outside the domains
1713
+ // asked for is left out
1714
+ if (hosted.allowed.length && !hosted.allowed.some((d) => inDomain(url, d))) continue
1715
+ if (hosted.blocked.some((d) => inDomain(url, d))) continue
1716
+ const title = typeof h.title === "string" && h.title.trim() ? h.title.trim() : url.href
1717
+ const age = typeof h.publishedDate === "string" && h.publishedDate ? h.publishedDate.slice(0, 64) : null
1718
+ // encrypted_content is the searcher's sealed copy of the page: Kiro
1719
+ // has none, and none is made up (as magpie's searchResultBlock)
1720
+ results.push({ type: "web_search_result", title, url: url.href, encrypted_content: "", page_age: age })
1721
+ const snippet = typeof h.snippet === "string" ? h.snippet.trim().slice(0, 2000) : ""
1722
+ lines.push(`[${results.length}] ${title}\nURL: ${url.href}` + (age ? `\nPublished: ${age}` : "") + (snippet ? `\n${snippet}` : ""))
1723
+ if (results.length >= SEARCH_RESULTS) break
1724
+ }
1725
+ return { results, text: lines.length ? lines.join("\n\n") : "No results." }
1726
+ }
1727
+
1442
1728
  // generate answers a Messages request through Kiro's API: once more,
1443
1729
  // refreshed, when Kiro turns the token down (403). As the built-in: no
1444
1730
  // credentials is a 401, a failed refresh after the 403 a 502; none marks
@@ -1446,39 +1732,176 @@ async function reply(it, model, stream, names = new Map()) {
1446
1732
  async function generate(creds, auth, req, signal) {
1447
1733
  const model = String(req.model ?? "auto")
1448
1734
  const budget = thinking(req, model)
1735
+ const hosted = hostedSearch(req)
1736
+ if (hosted?.error) return errorResponse(400, hosted.error)
1737
+ const first = await ask(creds, auth, req, model, budget, signal)
1738
+ if (!first.res) return errorResponse(first.status, first.message)
1739
+ const it = hosted ? searching(creds, auth, req, model, budget, signal, hosted, first) : events(first.res.body, model, budget)
1740
+ return kept(await reply(it, model, req.stream === true, toolNames(req)))
1741
+ }
1742
+
1743
+ // ask sends one turn to Kiro: { res, a }, a 200, or the { status, message }
1744
+ // of a failure.
1745
+ async function ask(creds, auth, req, model, budget, signal) {
1449
1746
  let a
1450
1747
  try {
1451
1748
  a = await creds(auth)
1452
1749
  } catch (e) {
1453
- return errorResponse(401, e.message)
1750
+ return { status: 401, message: e.message }
1454
1751
  }
1455
1752
  let res
1456
1753
  try {
1457
1754
  res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1458
1755
  } catch (e) {
1459
1756
  if (e?.name === "AbortError") throw e
1460
- return errorResponse(502, e.message)
1757
+ return { status: 502, message: e.message }
1461
1758
  }
1462
1759
  if (res.status === 403) {
1463
1760
  await res.body?.cancel()
1464
1761
  try {
1465
1762
  a = await creds(auth, true)
1466
1763
  } catch (e) {
1467
- return errorResponse(502, e.message)
1764
+ return { status: 502, message: e.message }
1468
1765
  }
1469
1766
  try {
1470
1767
  res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
1471
1768
  } catch (e) {
1472
1769
  if (e?.name === "AbortError") throw e
1473
- return errorResponse(502, e.message)
1770
+ return { status: 502, message: e.message }
1474
1771
  }
1475
1772
  }
1476
1773
  if (!res.ok) {
1477
1774
  // Kiro's own 401 is passed on, as the built-in did, the sign-in kept
1478
- const f = failure(res.status, (await res.text()).slice(0, 1 << 20))
1479
- return errorResponse(f.status, f.message)
1775
+ return failure(res.status, (await res.text()).slice(0, 1 << 20))
1776
+ }
1777
+ return { res, a }
1778
+ }
1779
+
1780
+ const aborted = () => Object.assign(new Error("the request was cancelled"), { name: "AbortError" })
1781
+
1782
+ // searching is events for a request offering the hosted web search, over
1783
+ // as many turns as its searches take: a call the model makes to it is
1784
+ // searched rather than passed on (serverTool, searched), and Kiro is asked
1785
+ // again with what was found, until it answers, calls a tool of the
1786
+ // caller's (passed on as ever, the searches of its turn with it), or has
1787
+ // taken hosted.uses + 2 turns (pause_turn, which the caller may send back
1788
+ // to go on). Searches past hosted.uses are max_uses_exceeded.
1789
+ async function* searching(creds, auth, req, model, budget, signal, hosted, first) {
1790
+ let { res, a } = first
1791
+ const messages = [...(req.messages ?? [])]
1792
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reported: false, searches: 0 }
1793
+ let refreshed = false
1794
+ for (let turn = 1; ; turn++) {
1795
+ const said = [] // this turn's blocks, for the next turn's history
1796
+ const calls = [] // its searches
1797
+ let client = false
1798
+ let current = null
1799
+ let stop = null
1800
+ for await (const e of events(res.body, model, budget)) {
1801
+ if (e.kind === "toolStart") {
1802
+ current = e.name === SEARCH_NAME ? { args: "" } : null
1803
+ if (current) calls.push(current)
1804
+ else {
1805
+ client = true
1806
+ said.push({ type: "tool_use", id: e.id, name: e.name, input: "" })
1807
+ yield e
1808
+ }
1809
+ } else if (e.kind === "toolArgs") {
1810
+ if (current) current.args += e.text
1811
+ else {
1812
+ const b = said.at(-1)
1813
+ if (b?.type === "tool_use") b.input += e.text
1814
+ yield e
1815
+ }
1816
+ } else if (e.kind === "stop") stop = e
1817
+ else {
1818
+ if (e.kind === "text") {
1819
+ const b = said.at(-1)
1820
+ if (b?.type === "text") b.text += e.text
1821
+ else said.push({ type: "text", text: e.text })
1822
+ }
1823
+ if (e.kind === "error") {
1824
+ // in a turn after a search, a status, not a finished answer (reply)
1825
+ yield turn > 1 ? { ...e, status: 502 } : e
1826
+ return
1827
+ }
1828
+ yield e
1829
+ }
1830
+ }
1831
+ if (signal?.aborted) throw aborted()
1832
+ if (!stop) return
1833
+ usage.input += stop.usage.input
1834
+ usage.output += stop.usage.output
1835
+ usage.cacheRead += stop.usage.cacheRead
1836
+ usage.cacheWrite += stop.usage.cacheWrite
1837
+ usage.reported ||= stop.usage.reported
1838
+ for (const b of said)
1839
+ if (b.type === "tool_use")
1840
+ try {
1841
+ b.input = b.input ? JSON.parse(b.input) : {}
1842
+ } catch {
1843
+ b.input = {}
1844
+ }
1845
+ if (!calls.length) {
1846
+ yield { ...stop, usage }
1847
+ return
1848
+ }
1849
+ for (const c of calls) {
1850
+ const id = "srvtoolu_" + randomBytes(12).toString("hex")
1851
+ let input = {}
1852
+ try {
1853
+ const v = c.args ? JSON.parse(c.args) : {}
1854
+ if (v && typeof v === "object" && !Array.isArray(v)) input = v
1855
+ } catch {}
1856
+ // why the model searched (PURPOSE) is kiro-cli's, not Anthropic's: the
1857
+ // caller sees the query alone, and only it is searched for
1858
+ const { [PURPOSE]: _why, ...shown } = input
1859
+ input = shown
1860
+ yield { kind: "serverTool", id, name: SEARCH_NAME, input }
1861
+ const query = typeof input.query === "string" ? input.query.trim() : ""
1862
+ let found
1863
+ if (!query) found = { code: "invalid_tool_input" }
1864
+ else if (query.length > SEARCH_QUERY) found = { code: "query_too_long" }
1865
+ else if (usage.searches >= hosted.uses) found = { code: "max_uses_exceeded" }
1866
+ else {
1867
+ usage.searches++
1868
+ found = await webSearch(a, query, hosted, signal)
1869
+ // a token Kiro turned down is refreshed once for the request, as a
1870
+ // turn's is
1871
+ if (found.status === 403 && !refreshed) {
1872
+ refreshed = true
1873
+ try {
1874
+ a = await creds(auth, true)
1875
+ found = await webSearch(a, query, hosted, signal)
1876
+ } catch (e) {
1877
+ if (e?.name === "AbortError" || signal?.aborted) throw e
1878
+ found = { code: "unavailable", why: secretless(e?.message) }
1879
+ }
1880
+ }
1881
+ if (found.status === 403) found = { code: "unavailable", why: "Kiro turned down the token for its web search (403)" }
1882
+ }
1883
+ const content = found.results ?? { type: "web_search_tool_result_error", error_code: found.code }
1884
+ keepSearched(id, found.results ? found.text : searchFailedText(found.code) + (found.why ? ` (${found.why})` : ""))
1885
+ yield { kind: "searched", id, content }
1886
+ said.push({ type: "server_tool_use", id, name: SEARCH_NAME, input }, { type: "web_search_tool_result", tool_use_id: id, content })
1887
+ }
1888
+ if (signal?.aborted) throw aborted()
1889
+ if (client) {
1890
+ yield { kind: "stop", reason: "tool_use", usage }
1891
+ return
1892
+ }
1893
+ if (turn >= hosted.uses + 2) {
1894
+ yield { kind: "stop", reason: "pause_turn", usage }
1895
+ return
1896
+ }
1897
+ messages.push({ role: "assistant", content: said })
1898
+ const next = await ask(creds, auth, { ...req, messages }, model, budget, signal)
1899
+ if (!next.res) {
1900
+ yield { kind: "error", status: next.status, text: `Kiro, after its web search: ${next.message} (${next.status})` }
1901
+ return
1902
+ }
1903
+ ;({ res, a } = next)
1480
1904
  }
1481
- return kept(await reply(events(res.body, model, budget), model, req.stream === true, toolNames(req)))
1482
1905
  }
1483
1906
 
1484
1907
  // ---- signing in ----------------------------------------------------------------------
@@ -1727,6 +2150,10 @@ export async function KiroAuthPlugin({ client } = {}) {
1727
2150
  name: "Kiro",
1728
2151
  npm: MESSAGES,
1729
2152
  api: BASE,
2153
+ // magpie's own field (#2027): Kiro answers Anthropic's hosted web
2154
+ // search itself (searching), so magpie passes a client's on as sent
2155
+ // rather than searching for it. The user's own setting goes over it.
2156
+ searches: true,
1730
2157
  ...was,
1731
2158
  models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
1732
2159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-kiro-auth",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Kiro subscriptions (Free, Pro, Pro+, Power) in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",