Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enabled: bool("OPENCODE_ENABLE_PARALLEL"),
legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"),
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
enableIflow: bool("OPENCODE_ENABLE_IFLOW"),
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
Expand Down
97 changes: 97 additions & 0 deletions packages/opencode/src/tool/iflow-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Duration, Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"

export const IFLOW_DEFAULT_BASE_URL = "https://platform.iflow.cn"

export function iflowBaseURL() {
return (process.env.IFLOW_BASE_URL || IFLOW_DEFAULT_BASE_URL).replace(/\/+$/, "")
}

export function requireIflowAPIKey(message: string) {
const key = process.env.IFLOW_API_KEY
if (!key) throw new Error(message)
return key
}

export function iflowURL(path: string) {
return `${iflowBaseURL()}${path.startsWith("/") ? path : `/${path}`}`
}

export const postJson = (
http: HttpClient.HttpClient,
path: string,
body: unknown,
missingKeyMessage: string,
timeout: Duration.Input,
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(iflowURL(path)).pipe(
HttpClientRequest.setHeaders({
Authorization: `Bearer ${requireIflowAPIKey(missingKeyMessage)}`,
"Content-Type": "application/json",
Accept: "application/json",
}),
HttpClientRequest.bodyJson(body),
)

const response = yield* http
.execute(request)
.pipe(Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("iFlow request timed out")) }))

const text = yield* response.text

if (response.status === 401 || response.status === 403) {
throw new Error("iFlow request was not authorized. Check IFLOW_API_KEY.")
}
if (response.status === 429) {
throw new Error("iFlow rate limit exceeded. Please try again later.")
}
if (response.status >= 500) {
throw new Error(`iFlow service error (${response.status}). Please try again later.`)
}
if (response.status < 200 || response.status >= 300) {
throw new Error(`iFlow request failed with status ${response.status}.`)
}

const data = yield* Effect.try({
try: () => JSON.parse(text),
catch: () => new Error("iFlow returned invalid JSON."),
})
const object = objectValue(data)
if (!object) throw new Error("iFlow returned invalid JSON.")
const businessError = getBusinessError(object)
if (businessError) throw new Error(businessError)
return object
})

function getBusinessError(data: Record<string, unknown>) {
if (data.success === false) return `iFlow request failed${formatMessage(data)}.`
const code = stringValue(data.code) ?? stringValue(data.status)
if (code && !["0", "200", "success", "ok"].includes(code.toLowerCase())) {
return `iFlow request failed${formatMessage(data)}.`
}
return undefined
}

function formatMessage(data: Record<string, unknown>) {
const message = stringValue(data.message) ?? stringValue(data.msg) ?? stringValue(data.error)
return message ? `: ${message}` : ""
}

export function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined
}

export function numberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}

export function objectValue(value: unknown) {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}

export function arrayValue(value: unknown) {
return Array.isArray(value) ? value : undefined
}
54 changes: 54 additions & 0 deletions packages/opencode/src/tool/iflow-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { objectValue, postJson, stringValue } from "./iflow-client"

export const IFLOW_FETCH_MISSING_KEY = "IFLOW_API_KEY is required when OPENCODE_WEBFETCH_PROVIDER=iflow."

export const fetch = (http: HttpClient.HttpClient, params: { url: string }) =>
Effect.gen(function* () {
const data = yield* postJson(
http,
"/api/search/webFetch",
{ url: params.url },
IFLOW_FETCH_MISSING_KEY,
"30 seconds",
)
return formatFetchResult(data, params.url)
})

export function formatFetchResult(data: Record<string, unknown>, fallbackURL: string) {
const source = findFetchData(data)
const title = stringValue(source.title)
const url = stringValue(source.url) ?? fallbackURL
const content =
stringValue(source.markdown) ??
stringValue(source.content) ??
stringValue(source.text) ??
stringValue(source.raw_content) ??
stringValue(source.rawContent)

return [
title ? `Title: ${title}` : undefined,
`URL: ${url}`,
content ? `Content:\n${content}` : "Content:\nNo content returned.",
]
.filter(Boolean)
.join("\n")
}

function findFetchData(data: Record<string, unknown>) {
const candidates = [
data.data,
data.result,
objectValue(data.data)?.result,
objectValue(data.data)?.page,
objectValue(data.data)?.document,
]

for (const candidate of candidates) {
const object = objectValue(candidate)
if (object) return object
}

return data
}
72 changes: 72 additions & 0 deletions packages/opencode/src/tool/iflow-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { arrayValue, numberValue, objectValue, postJson, stringValue } from "./iflow-client"

export const IFLOW_SEARCH_MISSING_KEY = "IFLOW_API_KEY is required when OPENCODE_WEBSEARCH_PROVIDER=iflow."

export const search = (http: HttpClient.HttpClient, params: { query: string; count?: number }) =>
Effect.gen(function* () {
const count = normalizeCount(params.count)
const data = yield* postJson(
http,
"/api/search/webSearch",
{ keywords: params.query, ...(count ? { num: count } : {}) },
IFLOW_SEARCH_MISSING_KEY,
"25 seconds",
)
return formatSearchResults(data)
})

export function normalizeCount(count: unknown) {
const value = numberValue(count)
if (!value) return undefined
return Math.min(Math.max(Math.trunc(value), 1), 20)
}

export function formatSearchResults(data: Record<string, unknown>) {
const results = findResults(data)
if (!results.length) return "No search results found. Please try a different query."

return results
.map((item, index) => {
const title = stringValue(item.title) ?? stringValue(item.name) ?? "Untitled"
const url = stringValue(item.url) ?? stringValue(item.link)
const snippet = stringValue(item.snippet) ?? stringValue(item.content) ?? stringValue(item.summary)
const published = stringValue(item.published_time) ?? stringValue(item.publishedTime)
const source = stringValue(item.source)

return [
`${index + 1}. ${title}`,
url ? `URL: ${url}` : undefined,
snippet ? `Snippet: ${snippet}` : undefined,
published ? `Published: ${published}` : undefined,
source ? `Source: ${source}` : undefined,
]
.filter(Boolean)
.join("\n")
})
.join("\n\n")
}

function findResults(data: Record<string, unknown>) {
const candidates = [
data.results,
data.webPages,
data.list,
data.items,
objectValue(data.data)?.results,
objectValue(data.data)?.organic,
objectValue(data.data)?.webPages,
objectValue(data.data)?.list,
objectValue(data.data)?.items,
objectValue(objectValue(data.data)?.webPages)?.value,
]

for (const candidate of candidates) {
const results = arrayValue(candidate)?.map(objectValue).filter((item): item is Record<string, unknown> => !!item)
if (results?.length) return results
}

const single = objectValue(data.data)
return single && (single.title || single.url || single.link) ? [single] : []
}
19 changes: 16 additions & 3 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"

export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
export function webSearchEnabled(
providerID: ProviderV2.ID,
flags: { exa?: boolean; parallel?: boolean; iflow?: boolean } = {},
) {
return (
providerID === ProviderV2.ID.opencode ||
flags.exa ||
flags.parallel ||
flags.iflow ||
process.env.OPENCODE_WEBSEARCH_PROVIDER === "iflow"
)
}

type TaskDef = Tool.InferDef<typeof TaskTool>
Expand Down Expand Up @@ -290,7 +299,11 @@ export const layer: Layer.Layer<
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
return webSearchEnabled(input.providerID, {
exa: flags.enableExa,
parallel: flags.enableParallel,
iflow: flags.enableIflow,
})
}

const usePatch =
Expand Down
36 changes: 29 additions & 7 deletions packages/opencode/src/tool/webfetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import * as Tool from "./tool"
import * as IflowFetch from "./iflow-fetch"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { isImageAttachment } from "@/util/media"
Expand All @@ -10,6 +11,15 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes

export type WebFetchProvider = "default" | "iflow"
type WebFetchMetadata = { provider?: "iflow" }

export function selectWebFetchProvider(): WebFetchProvider {
return process.env.OPENCODE_WEBFETCH_PROVIDER === "iflow" ? "iflow" : "default"
}

const emptyMetadata = (): WebFetchMetadata => ({})

export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
Expand All @@ -36,6 +46,8 @@ export const WebFetchTool = Tool.define(
throw new Error("URL must start with http:// or https://")
}

const provider = selectWebFetchProvider()

yield* ctx.ask({
permission: "webfetch",
patterns: [params.url],
Expand All @@ -44,9 +56,19 @@ export const WebFetchTool = Tool.define(
url: params.url,
format: params.format,
timeout: params.timeout,
...(provider === "iflow" ? { provider } : {}),
},
})

if (provider === "iflow") {
const output = yield* IflowFetch.fetch(http, { url: params.url })
return {
output,
title: `iFlow Web Fetch: ${params.url}`,
metadata: { provider },
}
}

const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)

// Build Accept header based on requested format with q parameters for fallbacks
Expand Down Expand Up @@ -112,7 +134,7 @@ export const WebFetchTool = Tool.define(
return {
title,
output: "Image fetched successfully",
metadata: {},
metadata: emptyMetadata(),
attachments: [
{
type: "file" as const,
Expand All @@ -133,22 +155,22 @@ export const WebFetchTool = Tool.define(
return {
output: markdown,
title,
metadata: {},
metadata: emptyMetadata(),
}
}
return { output: content, title, metadata: {} }
return { output: content, title, metadata: emptyMetadata() }

case "text":
if (contentType.includes("text/html")) {
return { output: extractTextFromHTML(content), title, metadata: {} }
return { output: extractTextFromHTML(content), title, metadata: emptyMetadata() }
}
return { output: content, title, metadata: {} }
return { output: content, title, metadata: emptyMetadata() }

case "html":
return { output: content, title, metadata: {} }
return { output: content, title, metadata: emptyMetadata() }

default:
return { output: content, title, metadata: {} }
return { output: content, title, metadata: emptyMetadata() }
}
}).pipe(Effect.orDie),
}
Expand Down
Loading
Loading