@magpie-community/opencode-kiro-auth 0.1.18 → 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.
- package/README.md +83 -1
- package/index.mjs +469 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,6 +62,9 @@ Refreshing such a sign-in:
|
|
|
62
62
|
plugin's `auth.refresh`, once for the account and before its requests,
|
|
63
63
|
models and usage need it. magpie saves the new token. A failed renewal,
|
|
64
64
|
even one Kiro refused, doesn't mark the account: it is tried again.
|
|
65
|
+
The usage read does: a sign-in Kiro refused there marks the account
|
|
66
|
+
signed out (`signIn: "expired"`), so magpie passes over it and offers
|
|
67
|
+
Sign in again on its row, and a clean read takes the mark off.
|
|
65
68
|
- OpenCode doesn't call that hook: there the token is refreshed 2 minutes
|
|
66
69
|
before it expires, before a request, and saved.
|
|
67
70
|
- Either way, a token Kiro turns down (a 403) is refreshed and the request
|
|
@@ -102,6 +105,12 @@ request:
|
|
|
102
105
|
that can't be read or brought under is replaced by a short note, so the
|
|
103
106
|
conversation goes on (#428). Smaller images are sent as they are.
|
|
104
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.
|
|
105
114
|
- It turns the AWS event stream Kiro answers with back into Messages'
|
|
106
115
|
events (streamed) or one message. The reply includes text, thinking,
|
|
107
116
|
tool calls and usage. When Kiro reports only how full the context is,
|
|
@@ -110,12 +119,86 @@ request:
|
|
|
110
119
|
the prompt, with a budget: low 10k, medium 20k, high 30k, xhigh and max
|
|
111
120
|
50k (as magpie's built-in Kiro: #120). The
|
|
112
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)).
|
|
113
124
|
- Kiro's failures keep their meaning:
|
|
114
125
|
- input too long → 400
|
|
115
126
|
- no capacity → 503
|
|
116
127
|
- usage limit → 429
|
|
117
128
|
- throttling → "rate limited"
|
|
118
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
|
+
|
|
119
202
|
## Models
|
|
120
203
|
|
|
121
204
|
The `config` hook declares `auto` (Kiro picks the model). Once signed in,
|
|
@@ -131,4 +214,3 @@ and Auto have the thinking variants.
|
|
|
131
214
|
provider.
|
|
132
215
|
- Signing in with a company's own identity provider in the browser (use
|
|
133
216
|
kiro-cli's sign-in).
|
|
134
|
-
- magpie's web-search stand-in for Kiro.
|
package/index.mjs
CHANGED
|
@@ -219,7 +219,8 @@ const EXPIRED_CLI = EXPIRED + " with `kiro-cli login` or the Kiro IDE"
|
|
|
219
219
|
|
|
220
220
|
// gone is an error saying the sign-in itself is gone — refused, or never
|
|
221
221
|
// there. The built-in marked no Kiro account lapsed, not even then, nor
|
|
222
|
-
// cleared one: every answer
|
|
222
|
+
// cleared one: every answer says the account is kept. The usage read says
|
|
223
|
+
// expired for it, and renewed for a clean read (auth.usage).
|
|
223
224
|
const gone = (msg) => Object.assign(new Error(msg), { gone: true })
|
|
224
225
|
const EARLY_MS = 2 * 60 * 1000 // a token this close to its end is refreshed before a request
|
|
225
226
|
const LEAD_MS = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
|
|
@@ -844,6 +845,21 @@ function formatText(req) {
|
|
|
844
845
|
return "Respond with a single JSON value and nothing else, matching this JSON schema:\n" + JSON.stringify(f.schema) + "."
|
|
845
846
|
}
|
|
846
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
|
+
|
|
847
863
|
// buildKiro is the body of a generateAssistantResponse call, as it is
|
|
848
864
|
// before its images are fitted (kiroBody).
|
|
849
865
|
function buildKiro(req, model, profile, budget) {
|
|
@@ -865,26 +881,53 @@ function kiroRequest(req, model, profile, budget) {
|
|
|
865
881
|
if (parts.some((p) => p?.cache_control)) marked.add(msg)
|
|
866
882
|
}
|
|
867
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
|
|
868
885
|
for (const m of req.messages ?? []) {
|
|
869
886
|
const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
|
|
870
887
|
const texts = []
|
|
871
888
|
if (m.role === "assistant") {
|
|
872
|
-
|
|
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
|
+
}
|
|
873
905
|
for (const p of parts) {
|
|
874
906
|
if (p?.type === "text" && p.text) texts.push(p.text)
|
|
875
907
|
else if (p?.type === "tool_use") {
|
|
876
908
|
const input = p.input && typeof p.input === "object" && !Array.isArray(p.input) ? p.input : {}
|
|
877
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
|
+
}
|
|
878
927
|
}
|
|
879
928
|
}
|
|
880
|
-
|
|
881
|
-
if (
|
|
882
|
-
const prev = entries.at(-1)?.assistantResponseMessage
|
|
883
|
-
if (prev) {
|
|
884
|
-
prev.content = joinNonEmpty(prev.content, a.content)
|
|
885
|
-
prev.toolUses.push(...a.toolUses)
|
|
886
|
-
} else entries.push({ assistantResponseMessage: a })
|
|
887
|
-
mark(prev ?? a, parts)
|
|
929
|
+
flush()
|
|
930
|
+
if (last) mark(last, parts)
|
|
888
931
|
continue
|
|
889
932
|
}
|
|
890
933
|
const u = user()
|
|
@@ -980,11 +1023,19 @@ function kiroRequest(req, model, profile, budget) {
|
|
|
980
1023
|
const tools = []
|
|
981
1024
|
const offered = new Set()
|
|
982
1025
|
const EMPTY = { type: "object", properties: {} }
|
|
1026
|
+
const hosted = hostedSearch(req)
|
|
983
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
|
+
}
|
|
984
1035
|
if (!t?.name || (t.type && t.type !== "custom" && !t.input_schema)) continue // server tools Kiro hasn't
|
|
985
1036
|
const name = toolName(t.name)
|
|
986
1037
|
offered.add(name)
|
|
987
|
-
tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: t.input_schema
|
|
1038
|
+
tools.push({ toolSpecification: { name, description: t.description || t.name, inputSchema: { json: inputSchema(t.input_schema) } } })
|
|
988
1039
|
if (t.cache_control) tools.push({ cachePoint: { type: "default" } })
|
|
989
1040
|
}
|
|
990
1041
|
for (const e of entries)
|
|
@@ -1015,21 +1066,24 @@ function kiroRequest(req, model, profile, budget) {
|
|
|
1015
1066
|
return out
|
|
1016
1067
|
}
|
|
1017
1068
|
|
|
1018
|
-
|
|
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) {
|
|
1019
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) {
|
|
1020
1083
|
return fetch(runtime(a.region) + "/generateAssistantResponse", {
|
|
1021
1084
|
method: "POST",
|
|
1022
|
-
headers: {
|
|
1023
|
-
|
|
1024
|
-
"Content-Type": "application/json",
|
|
1025
|
-
Accept: "application/vnd.amazon.eventstream",
|
|
1026
|
-
"x-amzn-codewhisperer-optout": "true",
|
|
1027
|
-
"amz-sdk-invocation-id": randomUUID(),
|
|
1028
|
-
"amz-sdk-request": "attempt=1; max=1",
|
|
1029
|
-
"x-amzn-kiro-agent-mode": "vibe",
|
|
1030
|
-
"x-amz-user-agent": ua,
|
|
1031
|
-
"User-Agent": ua,
|
|
1032
|
-
},
|
|
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" },
|
|
1033
1087
|
body,
|
|
1034
1088
|
signal,
|
|
1035
1089
|
})
|
|
@@ -1318,10 +1372,13 @@ function finished(content) {
|
|
|
1318
1372
|
// Kiro read from its cache is unknown, not 0: the cache fields are left
|
|
1319
1373
|
// out and magpie is told so (magpie_cache_unknown), so it shows no hit
|
|
1320
1374
|
// rate rather than 0%.
|
|
1321
|
-
const anthropicUsage = (u) =>
|
|
1322
|
-
u.reported
|
|
1375
|
+
const anthropicUsage = (u) => ({
|
|
1376
|
+
...(u.reported
|
|
1323
1377
|
? { input_tokens: u.input, output_tokens: u.output, cache_read_input_tokens: u.cacheRead, cache_creation_input_tokens: u.cacheWrite }
|
|
1324
|
-
: { 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
|
+
})
|
|
1325
1382
|
|
|
1326
1383
|
// quotaWords are magpie's (internal/gateway/fallback.go): how a vendor says
|
|
1327
1384
|
// "out of quota" or "slow down".
|
|
@@ -1354,11 +1411,16 @@ async function reply(it, model, stream, names = new Map()) {
|
|
|
1354
1411
|
else add({ type: "thinking", thinking: e.text, signature: "" })
|
|
1355
1412
|
} else if (e.kind === "sig") {
|
|
1356
1413
|
if (last?.type === "thinking") last.signature += e.text
|
|
1357
|
-
} else if (e.kind === "
|
|
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: "" })
|
|
1358
1417
|
else if (e.kind === "toolArgs") {
|
|
1359
1418
|
if (last?.type === "tool_use") last.input += e.text
|
|
1360
1419
|
} else if (e.kind === "error") {
|
|
1361
|
-
// 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)
|
|
1362
1424
|
if (!content.length) return failedBefore(e.text)
|
|
1363
1425
|
return Response.json({ id, type: "message", role: "assistant", model, content: finished(content), stop_reason: "end_turn",
|
|
1364
1426
|
stop_sequence: null, usage: anthropicUsage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) })
|
|
@@ -1420,6 +1482,15 @@ async function reply(it, model, stream, names = new Map()) {
|
|
|
1420
1482
|
case "toolArgs":
|
|
1421
1483
|
if (open === "tool") ctl.enqueue(sse("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: e.text } }))
|
|
1422
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
|
|
1423
1494
|
case "error":
|
|
1424
1495
|
close()
|
|
1425
1496
|
ctl.enqueue(sse("error", { error: { type: "api_error", message: e.text } }))
|
|
@@ -1438,6 +1509,222 @@ async function reply(it, model, stream, names = new Map()) {
|
|
|
1438
1509
|
return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
|
|
1439
1510
|
}
|
|
1440
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
|
+
|
|
1441
1728
|
// generate answers a Messages request through Kiro's API: once more,
|
|
1442
1729
|
// refreshed, when Kiro turns the token down (403). As the built-in: no
|
|
1443
1730
|
// credentials is a 401, a failed refresh after the 403 a 502; none marks
|
|
@@ -1445,39 +1732,176 @@ async function reply(it, model, stream, names = new Map()) {
|
|
|
1445
1732
|
async function generate(creds, auth, req, signal) {
|
|
1446
1733
|
const model = String(req.model ?? "auto")
|
|
1447
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) {
|
|
1448
1746
|
let a
|
|
1449
1747
|
try {
|
|
1450
1748
|
a = await creds(auth)
|
|
1451
1749
|
} catch (e) {
|
|
1452
|
-
return
|
|
1750
|
+
return { status: 401, message: e.message }
|
|
1453
1751
|
}
|
|
1454
1752
|
let res
|
|
1455
1753
|
try {
|
|
1456
1754
|
res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
|
|
1457
1755
|
} catch (e) {
|
|
1458
1756
|
if (e?.name === "AbortError") throw e
|
|
1459
|
-
return
|
|
1757
|
+
return { status: 502, message: e.message }
|
|
1460
1758
|
}
|
|
1461
1759
|
if (res.status === 403) {
|
|
1462
1760
|
await res.body?.cancel()
|
|
1463
1761
|
try {
|
|
1464
1762
|
a = await creds(auth, true)
|
|
1465
1763
|
} catch (e) {
|
|
1466
|
-
return
|
|
1764
|
+
return { status: 502, message: e.message }
|
|
1467
1765
|
}
|
|
1468
1766
|
try {
|
|
1469
1767
|
res = await sendKiro(a, await kiroBody(req, model, a.profile, budget), signal)
|
|
1470
1768
|
} catch (e) {
|
|
1471
1769
|
if (e?.name === "AbortError") throw e
|
|
1472
|
-
return
|
|
1770
|
+
return { status: 502, message: e.message }
|
|
1473
1771
|
}
|
|
1474
1772
|
}
|
|
1475
1773
|
if (!res.ok) {
|
|
1476
1774
|
// Kiro's own 401 is passed on, as the built-in did, the sign-in kept
|
|
1477
|
-
|
|
1478
|
-
|
|
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)
|
|
1479
1904
|
}
|
|
1480
|
-
return kept(await reply(events(res.body, model, budget), model, req.stream === true, toolNames(req)))
|
|
1481
1905
|
}
|
|
1482
1906
|
|
|
1483
1907
|
// ---- signing in ----------------------------------------------------------------------
|
|
@@ -1697,8 +2121,10 @@ export async function KiroAuthPlugin({ client } = {}) {
|
|
|
1697
2121
|
},
|
|
1698
2122
|
}
|
|
1699
2123
|
},
|
|
1700
|
-
// the account's credits, as magpie's built-in showed them
|
|
1701
|
-
//
|
|
2124
|
+
// the account's credits, as magpie's built-in showed them. A sign-in
|
|
2125
|
+
// Kiro refused (gone) marks the account signed out, so magpie passes
|
|
2126
|
+
// over it and its row offers Sign in again; a clean read takes the
|
|
2127
|
+
// mark off, as after `kiro-cli login`. Any other failure keeps it.
|
|
1702
2128
|
async usage(getAuth) {
|
|
1703
2129
|
const auth = await getAuth()
|
|
1704
2130
|
if (!(auth?.type === "api" && auth.key) && auth?.type !== "oauth") return { error: "not signed in", signIn: "kept" }
|
|
@@ -1706,9 +2132,9 @@ export async function KiroAuthPlugin({ client } = {}) {
|
|
|
1706
2132
|
const u = usageOf(await usageLimits(await creds(auth), true))
|
|
1707
2133
|
// named as the account is
|
|
1708
2134
|
if (u.user && auth.type === "oauth" && !auth.source) u.user = nameOf(u.user, auth.loginProvider)
|
|
1709
|
-
return { ...u, signIn: "
|
|
2135
|
+
return { ...u, signIn: "renewed" }
|
|
1710
2136
|
} catch (e) {
|
|
1711
|
-
return { error: e.message, signIn: "kept" }
|
|
2137
|
+
return { error: e.message, signIn: e.gone ? "expired" : "kept" }
|
|
1712
2138
|
}
|
|
1713
2139
|
},
|
|
1714
2140
|
methods: [
|
|
@@ -1724,6 +2150,10 @@ export async function KiroAuthPlugin({ client } = {}) {
|
|
|
1724
2150
|
name: "Kiro",
|
|
1725
2151
|
npm: MESSAGES,
|
|
1726
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,
|
|
1727
2157
|
...was,
|
|
1728
2158
|
models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
|
|
1729
2159
|
}
|