mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(llm): activate default model after OpenAI OAuth and gate provider checks by enabled
OAuth token save now promotes the first available chat model when no usable default exists. Default-model resolution and provider availability checks require Provider.enabled=true alongside credentials, so disabled Providers no longer return stale defaults. /models/enabled drops the single-Provider hard-code so OpenAI OAuth and other enabled chat models surface in selectors. Docker exposes the 1455 PKCE callback via MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST (default 127.0.0.1) and a port mapping; deployment mode stays Host-driven.
This commit is contained in:
parent
27f614d48a
commit
5f77434953
19
.env.example
19
.env.example
@ -50,6 +50,25 @@ MATECLAW_BROWSER_CDP_URL=
|
|||||||
MATECLAW_BROWSER_CHROME_PATH=
|
MATECLAW_BROWSER_CHROME_PATH=
|
||||||
MATECLAW_BROWSER_CHANNEL=
|
MATECLAW_BROWSER_CHANNEL=
|
||||||
|
|
||||||
|
# ==================== OpenAI OAuth(Docker,可选) ====================
|
||||||
|
#
|
||||||
|
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code,
|
||||||
|
# 不需要自定义 client secret。
|
||||||
|
#
|
||||||
|
# 默认留空即可。后端会根据访问 Host 自动选择:
|
||||||
|
# - localhost / 127.0.0.1 / ::1 → LOCAL(PKCE 回调)
|
||||||
|
# - IP / 域名 / 反向代理访问 → DEVICE_CODE(无缝远程授权)
|
||||||
|
#
|
||||||
|
# 本机 Docker 若希望像桌面版一样直接通过宿主机浏览器完成
|
||||||
|
# http://localhost:1455/auth/callback 回调,可显式开启 LOCAL,并让容器内
|
||||||
|
# 临时回调服务监听 0.0.0.0,以便通过 `1455:1455` 端口映射被宿主机访问到:
|
||||||
|
# MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=local
|
||||||
|
# MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=0.0.0.0
|
||||||
|
#
|
||||||
|
# 强制模式调试时也可设为:local / device_code / manual_paste
|
||||||
|
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
|
||||||
|
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
|
||||||
|
|
||||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||||
|
|||||||
@ -92,12 +92,17 @@ services:
|
|||||||
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
|
||||||
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
|
||||||
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
|
||||||
|
# OAuth 模式默认保持 auto:localhost 访问走 LOCAL,IP/域名访问走 DEVICE_CODE。
|
||||||
|
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
|
||||||
|
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
|
||||||
|
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
|
||||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||||
# recommendation for Playwright / headless chrome.
|
# recommendation for Playwright / headless chrome.
|
||||||
shm_size: 2gb
|
shm_size: 2gb
|
||||||
ports:
|
ports:
|
||||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||||
|
- "1455:1455"
|
||||||
volumes:
|
volumes:
|
||||||
- server_data:/app/data
|
- server_data:/app/data
|
||||||
|
|
||||||
|
|||||||
@ -100,4 +100,5 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
|||||||
|
|
||||||
COPY --from=builder /build/target/*.jar app.jar
|
COPY --from=builder /build/target/*.jar app.jar
|
||||||
EXPOSE 18088
|
EXPOSE 18088
|
||||||
|
EXPOSE 1455
|
||||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import org.springframework.web.client.RestClient;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.repository.ModelProviderMapper;
|
import vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
@ -73,9 +74,11 @@ public class OpenAIOAuthService {
|
|||||||
private static final String SCOPES = "openid profile email offline_access";
|
private static final String SCOPES = "openid profile email offline_access";
|
||||||
private static final String PROVIDER_ID = "openai-chatgpt";
|
private static final String PROVIDER_ID = "openai-chatgpt";
|
||||||
private static final int CALLBACK_PORT = 1455;
|
private static final int CALLBACK_PORT = 1455;
|
||||||
|
private static final String DEFAULT_CALLBACK_BIND_HOST = "127.0.0.1";
|
||||||
|
|
||||||
private final ModelProviderMapper modelProviderMapper;
|
private final ModelProviderMapper modelProviderMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
private final RestClient restClient = RestClient.create();
|
private final RestClient restClient = RestClient.create();
|
||||||
|
|
||||||
/** state → code_verifier 缓存 */
|
/** state → code_verifier 缓存 */
|
||||||
@ -240,15 +243,17 @@ public class OpenAIOAuthService {
|
|||||||
|
|
||||||
// Try to bind synchronously up front so callers can detect failure.
|
// Try to bind synchronously up front so callers can detect failure.
|
||||||
HttpServer server;
|
HttpServer server;
|
||||||
|
String bindHost = resolveCallbackBindHost();
|
||||||
try {
|
try {
|
||||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", CALLBACK_PORT), 0);
|
server = HttpServer.create(new InetSocketAddress(bindHost, CALLBACK_PORT), 0);
|
||||||
} catch (java.net.BindException e) {
|
} catch (java.net.BindException e) {
|
||||||
log.warn("OAuth callback bind failed on port {} (in-use or restricted): {}",
|
log.warn("OAuth callback bind failed on {}:{} (in-use or restricted): {}",
|
||||||
CALLBACK_PORT, e.getMessage());
|
bindHost, CALLBACK_PORT, e.getMessage());
|
||||||
pendingStates.remove(expectedState);
|
pendingStates.remove(expectedState);
|
||||||
return false;
|
return false;
|
||||||
} catch (java.io.IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
log.warn("OAuth callback HttpServer.create IO error: {}", e.getMessage());
|
log.warn("OAuth callback HttpServer.create IO error on {}:{}: {}",
|
||||||
|
bindHost, CALLBACK_PORT, e.getMessage());
|
||||||
pendingStates.remove(expectedState);
|
pendingStates.remove(expectedState);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -311,7 +316,8 @@ public class OpenAIOAuthService {
|
|||||||
|
|
||||||
boundServer.start();
|
boundServer.start();
|
||||||
activeCallbackServer = boundServer;
|
activeCallbackServer = boundServer;
|
||||||
log.info("OAuth 回调服务器已启动在 http://127.0.0.1:{}", CALLBACK_PORT);
|
log.info("OAuth 回调服务器已启动,监听 {}:{},浏览器回调地址 {}",
|
||||||
|
bindHost, CALLBACK_PORT, REDIRECT_URI);
|
||||||
|
|
||||||
// 3 分钟超时自动关闭
|
// 3 分钟超时自动关闭
|
||||||
CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> {
|
CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> {
|
||||||
@ -490,6 +496,7 @@ public class OpenAIOAuthService {
|
|||||||
provider.setOauthAccountId(accountId);
|
provider.setOauthAccountId(accountId);
|
||||||
}
|
}
|
||||||
modelProviderMapper.updateById(provider);
|
modelProviderMapper.updateById(provider);
|
||||||
|
modelProviderService.activateFirstModelIfDefaultUnavailable(PROVIDER_ID);
|
||||||
log.info("OpenAI OAuth token 已保存,expires_in={}s, accountId={}", expiresIn, accountId);
|
log.info("OpenAI OAuth token 已保存,expires_in={}s, accountId={}", expiresIn, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -536,6 +543,15 @@ public class OpenAIOAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String resolveCallbackBindHost() {
|
||||||
|
String configured = System.getProperty("mateclaw.oauth.openai.callback-bind-host",
|
||||||
|
System.getenv("MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST"));
|
||||||
|
if (!StringUtils.hasText(configured)) {
|
||||||
|
return DEFAULT_CALLBACK_BIND_HOST;
|
||||||
|
}
|
||||||
|
return configured.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== PKCE 工具 ====================
|
// ==================== PKCE 工具 ====================
|
||||||
|
|
||||||
private String generateCodeVerifier() {
|
private String generateCodeVerifier() {
|
||||||
|
|||||||
@ -43,10 +43,10 @@ public class ModelConfigService {
|
|||||||
public List<ModelConfigEntity> listEnabledModels() {
|
public List<ModelConfigEntity> listEnabledModels() {
|
||||||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||||
.eq(ModelConfigEntity::getEnabled, true)
|
.eq(ModelConfigEntity::getEnabled, true)
|
||||||
.eq(ModelConfigEntity::getProvider, "dashscope")
|
|
||||||
// 仅 chat 类型(排除 embedding),NULL 兼容老数据
|
// 仅 chat 类型(排除 embedding),NULL 兼容老数据
|
||||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||||
|
.orderByAsc(ModelConfigEntity::getProvider)
|
||||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||||
.orderByAsc(ModelConfigEntity::getName));
|
.orderByAsc(ModelConfigEntity::getName));
|
||||||
}
|
}
|
||||||
@ -131,7 +131,7 @@ public class ModelConfigService {
|
|||||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
if (defaultMarked != null && isProviderConfigured(defaultMarked.getProvider())) {
|
if (defaultMarked != null && isProviderEnabledAndConfigured(defaultMarked.getProvider())) {
|
||||||
return defaultMarked;
|
return defaultMarked;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,7 +144,7 @@ public class ModelConfigService {
|
|||||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||||
.orderByAsc(ModelConfigEntity::getName));
|
.orderByAsc(ModelConfigEntity::getName));
|
||||||
for (ModelConfigEntity candidate : candidates) {
|
for (ModelConfigEntity candidate : candidates) {
|
||||||
if (isProviderConfigured(candidate.getProvider())) {
|
if (isProviderEnabledAndConfigured(candidate.getProvider())) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,12 +165,12 @@ public class ModelConfigService {
|
|||||||
* dependency. Falls back to {@code true} when the service is not yet available
|
* dependency. Falls back to {@code true} when the service is not yet available
|
||||||
* (e.g., during early bootstrap) so we don't accidentally block startup.
|
* (e.g., during early bootstrap) so we don't accidentally block startup.
|
||||||
*/
|
*/
|
||||||
private boolean isProviderConfigured(String providerId) {
|
private boolean isProviderEnabledAndConfigured(String providerId) {
|
||||||
if (modelProviderService == null || providerId == null) {
|
if (modelProviderService == null || providerId == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return modelProviderService.isProviderConfigured(providerId);
|
return modelProviderService.isProviderEnabledAndConfigured(providerId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return true; // conservative: don't filter if lookup fails
|
return true; // conservative: don't filter if lookup fails
|
||||||
}
|
}
|
||||||
|
|||||||
@ -238,11 +238,14 @@ public class ModelProviderService {
|
|||||||
|
|
||||||
public boolean isProviderAvailable(String providerId) {
|
public boolean isProviderAvailable(String providerId) {
|
||||||
ModelProviderEntity provider = getProvider(providerId);
|
ModelProviderEntity provider = getProvider(providerId);
|
||||||
return isProviderConfigured(provider) && hasModels(providerId);
|
return isProviderEnabledAndConfigured(provider) && hasModels(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getProviderUnavailableReason(String providerId) {
|
public String getProviderUnavailableReason(String providerId) {
|
||||||
ModelProviderEntity provider = getProvider(providerId);
|
ModelProviderEntity provider = getProvider(providerId);
|
||||||
|
if (!Boolean.TRUE.equals(provider.getEnabled())) {
|
||||||
|
return "Provider 未启用";
|
||||||
|
}
|
||||||
if (!isProviderConfigured(provider)) {
|
if (!isProviderConfigured(provider)) {
|
||||||
// Issue #81: emit a precise reason based on which row-level fields are
|
// Issue #81: emit a precise reason based on which row-level fields are
|
||||||
// missing, rather than the previous protocol-blind heuristic. The new
|
// missing, rather than the previous protocol-blind heuristic. The new
|
||||||
@ -330,7 +333,7 @@ public class ModelProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
|
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
|
||||||
if (!isProviderConfigured(provider)) {
|
if (!isProviderEnabledAndConfigured(provider)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<ModelConfigEntity> providerModels = modelConfigService.listModelsByProvider(providerId);
|
List<ModelConfigEntity> providerModels = modelConfigService.listModelsByProvider(providerId);
|
||||||
@ -341,7 +344,7 @@ public class ModelProviderService {
|
|||||||
try {
|
try {
|
||||||
ModelConfigEntity currentDefault = modelConfigService.getDefaultModel();
|
ModelConfigEntity currentDefault = modelConfigService.getDefaultModel();
|
||||||
ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider());
|
ModelProviderEntity defaultProvider = modelProviderMapper.selectById(currentDefault.getProvider());
|
||||||
if (!isProviderConfigured(defaultProvider)) {
|
if (!isProviderEnabledAndConfigured(defaultProvider)) {
|
||||||
shouldAutoActivate = true;
|
shouldAutoActivate = true;
|
||||||
}
|
}
|
||||||
} catch (MateClawException e) {
|
} catch (MateClawException e) {
|
||||||
@ -353,6 +356,16 @@ public class ModelProviderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth/device-code completion updates credentials outside the normal provider
|
||||||
|
* config endpoint. Reuse the same default-model promotion logic so a freshly
|
||||||
|
* connected OAuth provider is immediately selectable by chat.
|
||||||
|
*/
|
||||||
|
public void activateFirstModelIfDefaultUnavailable(String providerId) {
|
||||||
|
ModelProviderEntity provider = getProvider(providerId);
|
||||||
|
tryAutoActivateModel(providerId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
private ModelProviderEntity getProvider(String providerId) {
|
private ModelProviderEntity getProvider(String providerId) {
|
||||||
ModelProviderEntity provider = modelProviderMapper.selectById(providerId);
|
ModelProviderEntity provider = modelProviderMapper.selectById(providerId);
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
@ -565,6 +578,16 @@ public class ModelProviderService {
|
|||||||
&& !"your-api-key-here".equalsIgnoreCase(normalized);
|
&& !"your-api-key-here".equalsIgnoreCase(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isProviderEnabledAndConfigured(String providerId) {
|
||||||
|
return isProviderEnabledAndConfigured(getProvider(providerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isProviderEnabledAndConfigured(ModelProviderEntity provider) {
|
||||||
|
return provider != null
|
||||||
|
&& Boolean.TRUE.equals(provider.getEnabled())
|
||||||
|
&& isProviderConfigured(provider);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, Object> readProviderGenerateKwargs(ModelProviderEntity provider) {
|
public Map<String, Object> readProviderGenerateKwargs(ModelProviderEntity provider) {
|
||||||
return readJson(provider != null ? provider.getGenerateKwargs() : null);
|
return readJson(provider != null ? provider.getGenerateKwargs() : null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package vip.mate.llm.oauth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
class OpenAIOAuthServiceTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearProperties() {
|
||||||
|
System.clearProperty("mateclaw.oauth.openai.callback-bind-host");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveCallbackBindHostDefaultsToLoopback() {
|
||||||
|
OpenAIOAuthService service = service();
|
||||||
|
|
||||||
|
assertEquals("127.0.0.1", service.resolveCallbackBindHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveCallbackBindHostUsesConfiguredProperty() {
|
||||||
|
System.setProperty("mateclaw.oauth.openai.callback-bind-host", "0.0.0.0");
|
||||||
|
OpenAIOAuthService service = service();
|
||||||
|
|
||||||
|
assertEquals("0.0.0.0", service.resolveCallbackBindHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
private OpenAIOAuthService service() {
|
||||||
|
return new OpenAIOAuthService(mock(ModelProviderMapper.class), new ObjectMapper(),
|
||||||
|
mock(ModelProviderService.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user