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
10 changes: 10 additions & 0 deletions backend/cli/bin/openscience
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ function run(target) {
console.error(result.error.message)
process.exit(1)
}
if (result.signal) {
console.error(
`openscience was terminated by ${result.signal}. The prebuilt native binary may be ` +
`incompatible with this host (platform ${os.platform()}, arch ${os.arch()}). ` +
`Some ARM64 hosts (e.g. 64KB page-size kernels) cannot run the prebuilt binary. ` +
`Try reinstalling, set OPENSCIENCE_BIN_PATH to a compatible binary, or report at ` +
`https://github.com/synthetic-sciences/openscience/issues`,
)
process.exit(1)
}
const code = typeof result.status === "number" ? result.status : 0
process.exit(code)
}
Expand Down
9 changes: 7 additions & 2 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,9 +811,14 @@ export namespace Provider {
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
// #192: this is a placeholder for a whitelisted model NOT in the
// local catalog — guessing `false` here silently drops images/PDFs
// for what may well be a vision-capable model. Guess permissive
// instead: a genuinely-unsupported attachment surfaces a real
// provider error rather than a fabricated "unsupported" one.
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
Expand Down
11 changes: 10 additions & 1 deletion backend/cli/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,16 @@ export namespace ProviderTransform {
if (part.type === "file") return fileToText(part)
return part
}
if (model.capabilities.input[modality]) return part
// #192: fine-grained input.image/input.pdf can be missing/wrong in the
// catalog (e.g. the synthetic OpenRouter model) even though the model
// is flagged attachment-capable. Fall back to the coarse `attachment`
// capability for image/pdf only — audio/video stay gated on their own
// modality flag.
if (
model.capabilities.input[modality] ||
(model.capabilities.attachment && (modality === "image" || modality === "pdf"))
)
return part

const name = filename ? `"${filename}"` : modality
return {
Expand Down
54 changes: 54 additions & 0 deletions backend/cli/test/installation/launcher-signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import os from "node:os"
import path from "node:path"
import { chmod, copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"

// The launcher (backend/cli/bin/openscience) is a plain CJS Node script. When
// the resolved binary is killed by a signal (SIGSEGV/SIGILL on some ARM64
// hosts, per #190), spawnSync returns status: null, signal: "SIG...", and the
// launcher must not silently exit 0 — see the task brief for the confirmed
// root cause.
const launcherSource = path.join(__dirname, "../../bin/openscience")

describe("launcher signal handling (#190)", () => {
test("exits non-zero with an actionable diagnostic when the binary is killed by a signal", async () => {
// CRITICAL SAFETY: run() destructively cleans ~/.openscience (unlinks
// files, rmSync's node_modules). HOME must point at a throwaway temp dir
// so this test never touches the real home directory.
const tmpHome = await mkdtemp(path.join(os.tmpdir(), "openscience-signal-home-"))
const tmpBin = await mkdtemp(path.join(os.tmpdir(), "openscience-signal-bin-"))
const crashScript = path.join(tmpBin, "crash.sh")
// Run the launcher from a copy outside this repo's tree: this repo's own
// package.json declares "type": "module", which would make a bare `node
// <path>` load the extension-less script as ESM and blow up on `require`
// before any launcher logic runs. The published wrapper package (see
// script/publish.ts) ships a package.json with no "type" field, so real
// installs default to CommonJS — copying to a bare temp dir (no ambient
// package.json) reproduces that real-world resolution instead.
const launcherCopy = path.join(tmpBin, "openscience")

try {
// A shell script that signals itself SEGV. isBinary() in the launcher
// accepts any existing non-.js file that isn't the wrapper itself, so
// this stands in for a Bun binary crashing on an incompatible host.
await writeFile(crashScript, "#!/bin/sh\nkill -s SEGV $$\n")
await chmod(crashScript, 0o755)
await copyFile(launcherSource, launcherCopy)

const proc = Bun.spawn(["node", launcherCopy, "some-arg"], {
env: { ...process.env, HOME: tmpHome, OPENSCIENCE_BIN_PATH: crashScript },
stdout: "pipe",
stderr: "pipe",
})

const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited])

expect(exitCode).not.toBe(0)
expect(stderr).toContain("SIGSEGV")
expect(stderr).toContain("incompatible")
} finally {
await rm(tmpHome, { recursive: true, force: true })
await rm(tmpBin, { recursive: true, force: true })
}
})
})
179 changes: 179 additions & 0 deletions backend/cli/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,185 @@ describe("ProviderTransform.message - unsupported file attachments", () => {
})
})

describe("ProviderTransform.message - image/pdf attachment fallback (#192)", () => {
// #192: images were falsely rejected as "this model doesn't support image
// input" for vision-capable models whose fine-grained input.image/input.pdf
// flag was missing/wrong in the catalog (notably the synthetic OpenRouter
// model, which hardcoded these false). The gate must fall back to the
// coarse `attachment` capability for image/pdf so it isn't blocked purely
// on a missing modality flag — but a model with attachment:false is
// genuinely incapable and must still get the ERROR text.
const b64 = (s: string) => Buffer.from(s).toString("base64")
const validImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="

const createModel = (capabilityOverrides: Record<string, unknown>) =>
({
id: "openrouter/some-vision-model",
providerID: "openrouter",
api: { id: "some-vision-model", url: "https://openrouter.ai/api/v1", npm: "@openrouter/ai-sdk-provider" },
name: "Some Vision Model",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
...capabilityOverrides,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 128000, output: 8192 },
status: "active",
options: {},
headers: {},
release_date: "",
}) as any

test("image part survives when input.image=false but attachment=true (fallback)", () => {
const model = createModel({
attachment: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", image: `data:image/png;base64,${validImageBase64}` },
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validImageBase64}` })
})

test("image part still replaced with ERROR when input.image=false and attachment=false (genuinely incapable model)", () => {
const model = createModel({
attachment: false,
input: { text: true, audio: false, image: false, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image", image: `data:image/png;base64,${validImageBase64}` },
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

expect(result[0].content[1]).toEqual({
type: "text",
text: "ERROR: Cannot read image (this model does not support image input). Inform the user.",
})
})

test("image part survives when input.image=true regardless of attachment (regression)", () => {
const model = createModel({
attachment: false,
input: { text: true, audio: false, image: true, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [{ type: "image", image: `data:image/png;base64,${validImageBase64}` }],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

expect(result[0].content[0]).toEqual({ type: "image", image: `data:image/png;base64,${validImageBase64}` })
})

test("pdf file part survives when input.pdf=false but attachment=true (fallback)", () => {
const model = createModel({
attachment: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [
{
type: "file",
mediaType: "application/pdf",
filename: "doc.pdf",
data: `data:application/pdf;base64,${b64("%PDF-1.4 fake")}`,
},
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

expect((result[0].content as any[]).some((p: any) => p.type === "file")).toBe(true)
})

test("pdf file part replaced with ERROR when input.pdf=false and attachment=false", () => {
const model = createModel({
attachment: false,
input: { text: true, audio: false, image: false, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [
{
type: "file",
mediaType: "application/pdf",
filename: "doc.pdf",
data: `data:application/pdf;base64,${b64("%PDF-1.4 fake")}`,
},
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

const content = result[0].content as any[]
expect(content.some((p: any) => p.type === "file")).toBe(false)
expect(content[0]).toEqual({
type: "text",
text: 'ERROR: Cannot read "doc.pdf" (this model does not support pdf input). Inform the user.',
})
})

test("audio file part is NOT broadened by the attachment fallback (image/pdf only)", () => {
const model = createModel({
attachment: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
})
const msgs = [
{
role: "user",
content: [
{
type: "file",
mediaType: "audio/mpeg",
filename: "clip.mp3",
data: `data:audio/mpeg;base64,${b64("fake audio bytes")}`,
},
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {})

const content = result[0].content as any[]
expect(content.some((p: any) => p.type === "file")).toBe(false)
expect(content[0]).toEqual({
type: "text",
text: 'ERROR: Cannot read "clip.mp3" (this model does not support audio input). Inform the user.',
})
})
})

describe("ProviderTransform.message - providerOptions key remapping", () => {
const createModel = (providerID: string, npm: string) =>
({
Expand Down
Loading
Loading