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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ docker run --rm -p 8787:8787 \

实际命中的 provider 会在 `doctor` 输出的 `provider_selection.resolved` 里报告。

### OpenAI-compatible 服务预设与图片协议

服务预设和运行时协议是两层独立配置,避免把品牌名误当成兼容保证:

| 服务预设 | `preset` | 当前协议 | 说明 |
|---|---|---|---|
| OpenAI 官方 | `openai` | `openai-sync` | 官方 `/images/generations`、`/images/edits` |
| New API | `new-api` | `openai-sync` | 目前按标准 OpenAI Images 同步协议接入,没有 New API 专用适配层 |
| sub2api | `sub2api` | `openai-sync` 或 `sub2api-async` | 异步模式使用 `/images/*/async` 提交,再轮询 `/images/tasks/{task_id}` |
| 自定义服务 | `custom` | `openai-sync` | 由用户确认服务实现的 OpenAI-compatible 能力 |

真正决定请求行为的是 `image_transport`,`preset` 只负责 UI 默认值和说明文案。旧配置缺少这两个字段时仍按 `openai-sync` 执行,不会静默切换协议。前端新建 sub2api 凭证时会优先选择异步模式,但仍可显式改回同步。

sub2api 异步模式要求服务端启用 `image_storage`。停止 App/浏览器本地轮询不会取消远端任务;任务 ID 和最后状态会保留在历史记录中。桌面 App 与 Docker Web 可从失败任务选择“继续获取结果”,只恢复原 task ID 的轮询与落盘,不会重新提交生成请求。静态 Web 会保留 task ID,但刷新后的恢复仍建议改用 App/Docker。

默认轮询间隔为 3 秒、最长等待 1800 秒,可通过 provider 配置调整;`poll_interval_seconds` 是请求频率下限,即使服务端返回更短的 `Retry-After` 也不会更频繁地轮询。异步提交本身不会自动重发,避免“服务端已受理但响应丢失”时重复创建和计费。

## 核心能力

### 图像生成与编辑
Expand Down Expand Up @@ -319,6 +336,13 @@ gpt-image-2-skill --json config add-provider \
--name my-image-api --type openai-compatible \
--api-base https://example.com/v1 --api-key sk-... --set-default

# sub2api 异步任务协议
gpt-image-2-skill --json config add-provider \
--name my-sub2api --type openai-compatible --preset sub2api \
--image-transport sub2api-async --api-base https://example.com/v1 \
--api-key sk-... --poll-interval-seconds 3 \
--poll-timeout-seconds 1800

# 凭据存储到 macOS Keychain / Linux Secret Service / Windows Credential Manager
gpt-image-2-skill --json secret set --provider my-image-api --field api_key

Expand Down Expand Up @@ -356,6 +380,8 @@ gpt-image-2-skill --json \
"type": "openai-compatible",
"api_base": "https://example.com/v1",
"model": "gpt-image-2",
"preset": "new-api",
"image_transport": "openai-sync",
"credentials": {
"api_key": { "source": "file", "value": "sk-..." }
}
Expand Down Expand Up @@ -763,6 +789,23 @@ Selection (`--provider <value>`):

The actual provider used is reported under `provider_selection.resolved` in the `doctor` output.

### OpenAI-compatible service presets and image transports

Service presets and runtime transports are intentionally separate so a product name is not treated as a compatibility guarantee:

| Service preset | `preset` | Current transport | Behavior |
|---|---|---|---|
| Official OpenAI | `openai` | `openai-sync` | Official `/images/generations` and `/images/edits` |
| New API | `new-api` | `openai-sync` | Uses the standard synchronous OpenAI Images protocol; there is no New API-specific runtime adapter |
| sub2api | `sub2api` | `openai-sync` or `sub2api-async` | Async mode submits to `/images/*/async`, then polls `/images/tasks/{task_id}` |
| Custom service | `custom` | `openai-sync` | The user is responsible for confirming OpenAI-compatible behavior |

`image_transport` controls request behavior; `preset` only supplies UI defaults and explanatory copy. Existing configs without either field continue to use `openai-sync`, so no protocol changes happen silently. The UI initially selects async mode for a newly created sub2api credential, while still allowing an explicit switch back to sync.

sub2api async mode requires server-side `image_storage`. Stopping local polling in the App/browser does not cancel the remote task; the task ID and last status remain in history. Desktop App and Docker Web expose **Continue fetching result** on an interrupted task; this resumes polling and materialization for the existing task ID without submitting a new generation. Static Web preserves the task ID, but post-refresh recovery should still be completed in App/Docker.

Polling defaults to every 3 seconds with an 1800-second overall timeout and can be adjusted per provider. `poll_interval_seconds` is a lower bound, so a shorter server `Retry-After` never increases the configured polling rate. Async submissions themselves are not automatically retried, preventing duplicate task creation and billing when acceptance is uncertain.

## Capabilities

### Image generation and edit
Expand Down Expand Up @@ -886,6 +929,13 @@ gpt-image-2-skill --json config add-provider \
--name my-image-api --type openai-compatible \
--api-base https://example.com/v1 --api-key sk-... --set-default

# sub2api async task transport
gpt-image-2-skill --json config add-provider \
--name my-sub2api --type openai-compatible --preset sub2api \
--image-transport sub2api-async --api-base https://example.com/v1 \
--api-key sk-... --poll-interval-seconds 3 \
--poll-timeout-seconds 1800

# Store credentials in macOS Keychain / Linux Secret Service / Windows Credential Manager
gpt-image-2-skill --json secret set --provider my-image-api --field api_key

Expand Down Expand Up @@ -923,6 +973,8 @@ Example `config.json`:
"type": "openai-compatible",
"api_base": "https://example.com/v1",
"model": "gpt-image-2",
"preset": "new-api",
"image_transport": "openai-sync",
"credentials": {
"api_key": { "source": "file", "value": "sk-..." }
}
Expand Down
9 changes: 5 additions & 4 deletions apps/gpt-image-2-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ use gpt_image_2_core::{
preserve_notification_secrets, preserve_storage_secrets, product_app_data_dir,
product_default_export_dir, product_default_export_dirs, product_result_library_dir,
product_storage_fallback_dir, raw_response_path, read_job_output_from_storage_with_options,
read_keychain_secret, read_recent_logs, recovery_job_dir, redact_app_config, requested_n,
restore_deleted_history_job, run_json, save_app_config, shared_config_dir, show_history_job,
soft_delete_history_job, test_fault, upload_job_outputs_to_storage, upsert_history_job,
write_batch_recovery_summary, write_keychain_secret,
read_keychain_secret, read_recent_logs, recovery_job_dir, recovery_remote_task_entries,
redact_app_config, requested_n, restore_deleted_history_job, resume_sub2api_remote_task,
run_json, save_app_config, shared_config_dir, show_history_job, soft_delete_history_job,
test_fault, upload_job_outputs_to_storage, upsert_history_job, write_batch_recovery_summary,
write_keychain_secret,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
Expand Down
29 changes: 16 additions & 13 deletions apps/gpt-image-2-app/src-tauri/src/provider_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,20 @@ pub(crate) fn convert_provider_input(
}
None => None,
};
Ok((
ProviderConfig {
provider_type: input.provider_type,
api_base: input.api_base,
endpoint: input.endpoint,
model: input.model,
credentials,
supports_n: input.supports_n,
edit_region_mode: input.edit_region_mode,
proxy,
},
input.set_default,
))
let config = ProviderConfig {
provider_type: input.provider_type,
api_base: input.api_base,
endpoint: input.endpoint,
model: input.model,
credentials,
supports_n: input.supports_n,
edit_region_mode: input.edit_region_mode,
preset: input.preset,
image_transport: input.image_transport,
poll_interval_seconds: input.poll_interval_seconds,
poll_timeout_seconds: input.poll_timeout_seconds,
proxy,
};
gpt_image_2_core::validate_provider_config(&config).map_err(app_error)?;
Ok((config, input.set_default))
}
85 changes: 85 additions & 0 deletions apps/gpt-image-2-app/src-tauri/src/recovery_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) fn resume_job(
) -> Result<Value, String> {
match action.as_str() {
"continue_save" => continue_save_job(&job_id),
"resume_remote" => recover_remote_job(&job_id),
"fill_missing" => fill_missing_job(&job_id),
"reupload" => reupload_job(&job_id),
"resubmit" => retry_job(job_id, app, state),
Expand Down Expand Up @@ -139,6 +140,72 @@ pub(crate) fn continue_save_job(job_id: &str) -> Result<Value, String> {
}))
}

fn recover_remote_job(job_id: &str) -> Result<Value, String> {
let job = show_history_job(job_id).map_err(app_error)?;
let metadata = job.get("metadata").cloned().unwrap_or_else(|| json!({}));
let job_dir =
recovery_job_dir(&metadata).ok_or_else(|| "Recovery job dir is missing.".to_string())?;
if raw_response_path(&job_dir).is_file() {
return continue_save_job(job_id);
}
let provider = job
.get("provider")
.and_then(Value::as_str)
.filter(|provider| !provider.is_empty())
.ok_or_else(|| "远端任务缺少 provider,无法继续获取。".to_string())?;
let (remote_task, remote_tasks) = recovery_remote_task_entries(&metadata, Some(&job_dir));
if let Some(remote_task) = remote_task {
if matches!(
remote_task.status.as_str(),
"failed" | "cancelled" | "expired"
) {
return Err(format!(
"sub2api 远端任务 {} 已结束:{}",
remote_task.task_id, remote_task.status
));
}
resume_sub2api_remote_task(provider, job_id, &job_dir, &remote_task).map_err(app_error)?;
return continue_save_job(job_id);
}
if remote_tasks.is_empty() {
return Err("没有找到可继续获取的 sub2api 远端任务。".to_string());
}
for (index, remote_task) in remote_tasks {
if matches!(
remote_task.status.as_str(),
"failed" | "cancelled" | "expired"
) {
continue;
}
let slot = u8::try_from(index).map_err(|_| "远端任务图片序号超出范围。".to_string())?;
let child_dir = batch_recovery_job_dir(&job_dir, slot);
if raw_response_path(&child_dir).is_file() {
continue;
}
let child_id = batch_recovery_job_id(job_id, slot);
if let Err(error) =
resume_sub2api_remote_task(provider, &child_id, &child_dir, &remote_task)
{
log_event(
LogLevel::Warn,
"local",
"job.remote_recovery_failed",
json!({
"job_id": job_id,
"slot": index,
"task_id": remote_task.task_id,
"error": {
"code": error.code,
"message": error.message,
"detail": error.detail,
},
}),
);
}
}
fill_missing_job_with_policy(job_id, false)
}

fn expected_slot_count(metadata: &Value, job: &Value) -> usize {
metadata
.get("generation_slots")
Expand Down Expand Up @@ -204,6 +271,10 @@ fn materialize_cached_slot_payload(
}

fn fill_missing_job(job_id: &str) -> Result<Value, String> {
fill_missing_job_with_policy(job_id, true)
}

fn fill_missing_job_with_policy(job_id: &str, allow_resubmit: bool) -> Result<Value, String> {
let job = show_history_job(job_id).map_err(app_error)?;
let metadata = job.get("metadata").cloned().unwrap_or_else(|| json!({}));
let job_dir =
Expand Down Expand Up @@ -254,6 +325,13 @@ fn fill_missing_job(job_id: &str) -> Result<Value, String> {
}
continue;
}
if !allow_resubmit {
errors.push(BatchItemError::from_error_value(
*index,
error_value_from_message("没有可取回的远端结果;未重新提交图片生成请求。"),
));
continue;
}
match cli_json_result(&generate_args_with_recovery(
&request,
&out,
Expand Down Expand Up @@ -289,6 +367,13 @@ fn fill_missing_job(job_id: &str) -> Result<Value, String> {
}
continue;
}
if !allow_resubmit {
errors.push(BatchItemError::from_error_value(
*index,
error_value_from_message("没有可取回的远端结果;未重新提交图片编辑请求。"),
));
continue;
}
match cli_json_result(&edit_args_with_recovery(
&request,
&ref_paths,
Expand Down
4 changes: 4 additions & 0 deletions apps/gpt-image-2-app/src-tauri/src/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ pub(crate) fn config_for_ui(config: &AppConfig) -> Value {
"builtin": true,
"supports_n": false,
"edit_region_mode": "reference-hint",
"preset": "custom",
"image_transport": "openai-sync",
})
});
providers.entry("openai".to_string()).or_insert_with(|| {
Expand All @@ -233,6 +235,8 @@ pub(crate) fn config_for_ui(config: &AppConfig) -> Value {
"builtin": true,
"supports_n": true,
"edit_region_mode": "native-mask",
"preset": "openai",
"image_transport": "openai-sync",
})
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/gpt-image-2-app/src-tauri/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fn openai_compatible_provider() -> ProviderConfig {
supports_n: Some(false),
edit_region_mode: Some("reference-hint".to_string()),
proxy: None,
..ProviderConfig::default()
}
}

Expand Down
8 changes: 8 additions & 0 deletions apps/gpt-image-2-app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ pub(crate) struct ProviderInput {
#[serde(default)]
pub(crate) edit_region_mode: Option<String>,
#[serde(default)]
pub(crate) preset: Option<String>,
#[serde(default)]
pub(crate) image_transport: Option<String>,
#[serde(default)]
pub(crate) poll_interval_seconds: Option<u64>,
#[serde(default)]
pub(crate) poll_timeout_seconds: Option<u64>,
#[serde(default)]
pub(crate) proxy: Option<ProxyConfig>,
#[serde(default)]
pub(crate) set_default: bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export function JobDrawerFooter({
onCancel?: (id: string) => void;
onDelete?: (id: string) => void;
}) {
const asyncTaskActive = Boolean(
job.metadata?.async_task ||
job.metadata?.remote_task ||
(Array.isArray(job.metadata?.async_tasks) &&
job.metadata.async_tasks.length > 0) ||
(Array.isArray(job.metadata?.remote_tasks) &&
job.metadata.remote_tasks.length > 0),
);
return (
<div className="px-[18px] py-3 border-t border-border-faint flex flex-col gap-1.5">
{canCancel ? (
Expand All @@ -48,7 +56,7 @@ export function JobDrawerFooter({
className="w-full justify-center"
onClick={() => onCancel?.(job.id)}
>
取消任务
{asyncTaskActive && api.kind === "browser" ? "停止等待" : "取消任务"}
</Button>
) : (
<Button
Expand Down
Loading